对于工程团队来说,很难管理一个非常大的应用程序。因此,公司没有构建大型整体应用程序,而是编写了多个可以由多个小型团队管理的微服务。这些微服务与彼此之间相互松散,可以独立开发,部署和缩放。但是,在这种微服务体系结构中,微服务通常需要通过轻量级通信协议相互通信。对于这种过程间的交流,GRPC是开发人员的流行选择。
GRPC是用于远程过程调用的开源框架。它由Google开发。它是一个轻巧,高性能和类型的安全框架,用于服务间通信。它使用HTTP/2进行运输和协议缓冲区来序列化结构化数据。
在此博客中,我们将使用nodejs编写微服务,并使用grpc进行交流。
回购设置
我们将创建一个monorepo,我们将保留所有微服务和Protobuf文件。我们将拥有一个名为product-service
的服务器和一个客户端来测试服务器。
让我们创建我们的项目回购并将当前的工作目录更改为它。
mkdir nodejs-microservices
cd nodejs-microservices
我们将在其中创建一个空节点项目。在这里,我们将通过-y
标志接受所有默认值。
我们将使用npm workspace
进行本地共享依赖项分辨率。
npm init -y
我们将创建2个目录packages
和services
。在packages
目录中,我们将保留所有可重复使用的库,例如Protobuf等生成的打字稿文件,在services
目录中,我们将保留所有的微服务。
mkdir packages services
让我们首先创建protos
软件包。
注意:这不是使用Protobuf文件的理想方式,因为该版本未锁定在服务中。因此,我们在Protos软件包中所做的任何更改都将在服务中自动反思。最好将Protobuf文件保存在单独的存储库中,并将生成的打字稿文件发布到注册表中。并将其用作固定版本
的NPM软件包
npm init -w packages/protos --scope=@nodejs-microservices -y
在protos
软件包中,我们将创建2个目录src
和dist
。在src
目录中,我们将保留Protobuf文件,在dist
目录中,我们将从Protobuf文件中保留生成的打字稿文件。
cd packages/protos
mkdir src dist
之后,项目结构应该看起来像这样。
.
├── package.json
├── packages
│ └── protos
│ ├── dist
│ ├── package.json
│ └── src
└── services
现在,我们将在src
目录中创建Protobuf文件。此protos
软件包将包含多个Protobuf文件。因此,我们将根据原始软件包名称在src
目录内创建子目录。
创建Protobuf文件
对于product-service
,我们将在product
子目录中创建product.proto
文件。我们将在此处定义Product
消息。阅读有关Protobuf消息here
src/product/product.proto
syntax = "proto3";
package product;
import "google/protobuf/timestamp.proto";
message Product {
int32 id = 1;
string name = 2;
string description = 3;
string image = 4;
repeated string tags = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
我们将创建一个名为ProductService
的服务,并为RPC方法定义请求和响应的模式。 ProductService
将具有3种RPC方法CreateProduct
,GetProduct
和ListProducts
。
syntax = "proto3";
package product;
import "google/protobuf/timestamp.proto";
message Product {
int32 id = 1;
string name = 2;
string description = 3;
string image = 4;
repeated string tags = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
message CreateProductRequest {
string name = 1;
string description = 2;
string image = 3;
repeated string tags = 4;
}
message CreateProductResponse {
Product product = 1;
}
message GetProductRequest {
int32 id = 1;
}
message GetProductResponse {
Product product = 1;
}
message ListProductsRequest {
}
message ListProductsResponse {
repeated Product products = 1;
}
service ProductService {
rpc CreateProduct(CreateProductRequest) returns (CreateProductResponse);
rpc GetProduct(GetProductRequest) returns (GetProductResponse);
rpc ListProducts(ListProductsRequest) returns (ListProductsResponse);
}
从Protobuf生成打字稿文件
要生成打字稿文件,我们需要首先安装protobuf
编译器。 protobuf
编译器将在系统中安装。在这里,我通过Brew在MacOS安装。对于其他操作系统,它将有所不同。检查github repo以获取安装说明。
brew install protobuf
我们还需要安装ts-proto
NPM软件包,以将其用作protoc
的插件。我们将创建一个构建脚本来从Protbuf生成打字稿文件。在构建脚本中,我们将dist
设置为输出目录和src
作为Protobuf文件的源目录。
npm i -D ts-proto typescript
touch build.sh
build.sh
#!/bin/bash
protoc --plugin=$(npm root)/.bin/protoc-gen-ts_proto \
--ts_proto_out=dist \
--ts_proto_opt=outputServices=grpc-js \
--ts_proto_opt=esModuleInterop=true \
-I=src/ src/**/*.proto
在构建脚本中,我们使用的是$(npm root)
而不是./node_modules
,因为由于NPM工作区,二进制已安装在root node_modules
中。阅读有关npm root
here的更多信息。
我们将使构建脚本可执行并运行。
chmod +x build.sh
./build.sh
执行构建脚本后,将在dist
文件夹中生成打字稿文件,并且文件夹结构看起来像这样。
.
├── dist
│ ├── google
│ │ └── protobuf
│ │ └── timestamp.ts
│ └── product
│ └── product.ts
├── package.json
└── src
└── product
└── product.proto
创建GRPC服务器
让我们创建product-service
。 product-service
将是一个将产品数据存储在Postgres数据库中的GRPC服务器。我们将在此服务中使用typeorm
。
从仓库的根中,我们将运行NPM Init命令来踩下package.json文件。
npm init -w services/product-service --scope=@nodejs-microservices -y
然后将当前的工作目录更改为product-service
。在product-service
目录中,我们将创建src
和类似于protos
软件包的dist
子目录。
cd services/product-service
mkdir src dist
让我们安装服务器的依赖关系和DEV依赖项。
npm i -D typescript ts-node
npm i @grpc/grpc-js typeorm reflect-metadata pg
我们将在package.json
文件旁边的服务器目录内创建tsconfig.json
文件。
tsconfig.json
{
"compilerOptions": {
"target": "es2016",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
让我们为products
表创建模型。 id
是表的主要钥匙,将自动生成自动启动值。创建和更新的字段也将自动生成,并在插入和更新操作期间设置。
src/models/product.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id!: number;
@Column()
name!: string;
@Column()
description!: string;
@Column()
image!: string;
@Column("text", { array: true })
tags!: string[];
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
让我们配置数据库连接。我们将在src
目录中创建db/index.ts
文件。
我们必须在此处导入产品模型并将其添加到实体属性中。
src/db/index.ts
import { DataSource } from "typeorm";
import { Product } from "../models/product";
const dataSource = new DataSource({
type: "postgres",
host: process.env.POSTGRES_HOST || "localhost",
port: Number(process.env.POSTGRES_PORT) || 5432,
username: process.env.POSTGRES_USER || "postgres",
password: process.env.POSTGRES_PASSWORD || "postgres",
database: process.env.POSTGRES_DB || "postgres",
entities: [Product],
logging: true,
synchronize: true,
});
export default dataSource;
让我们设置GRPC服务器。我们将在src
目录中创建一个main.ts
文件。在这里,我们将导入dataSource
。初始化dataSource
后,我们将获得db
实例。我们将仅在连接数据库后才启动server
。
src/main.ts
import "reflect-metadata";
import dataSource from "./db";
import { Server, ServerCredentials } from "@grpc/grpc-js";
const server = new Server();
const HOST = process.env.HOST || "0.0.0.0";
const PORT = Number(process.env.PORT) || 50051;
const address = `${HOST}:${PORT}`;
dataSource
.initialize()
.then((db) => {
server.bindAsync(
address,
ServerCredentials.createInsecure(),
(error, port) => {
if (error) {
throw error;
}
console.log("server is running on", port);
server.start();
}
);
})
.catch((error) => console.log(error));
更新package.json
文件中的脚本以运行并构建服务器。
package.json
{
...
"scripts": {
"dev": "ts-node src/main.ts",
"build": "tsc",
"start": "node dist/main.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
...
}
我们可以通过运行服务器和数据库连接来测试它。它应该在数据库中创建products
表。
npm run dev
or
npm run build
npm start
让我们开始实现服务器的方法。首先,我们需要安装protos
软件包。然后我们创建一个server.ts
文件。
npm i @nodejs-microservices/protos
touch src/server.ts
我们将在server.ts
文件中创建一个getProductServer
函数,该文件将返回使用createProduct
,getProduct
和listProducts
方法的对象。我们正在使用此服务器对象而不是由于服务器签名issue
我们将稍后定义业务逻辑,现在所有方法都将在响应中返回状态UNIMPLEMENTED
。我们正在这样做以测试服务器。
src/server.ts
import { sendUnaryData, ServerUnaryCall, status } from "@grpc/grpc-js";
import {
CreateProductRequest,
CreateProductResponse,
GetProductRequest,
GetProductResponse,
ListProductsRequest,
ListProductsResponse,
ProductServiceServer,
} from "@nodejs-microservices/protos/dist/product/product";
import { DataSource } from "typeorm";
export function getProductServer(db: DataSource): ProductServiceServer {
async function createProduct(
call: ServerUnaryCall<CreateProductRequest, CreateProductResponse>,
callback: sendUnaryData<CreateProductResponse>
) {
callback({ code: status.UNIMPLEMENTED }, null);
}
async function getProduct(
call: ServerUnaryCall<GetProductRequest, GetProductResponse>,
callback: sendUnaryData<GetProductResponse>
) {
callback({ code: status.UNIMPLEMENTED }, null);
}
async function listProducts(
call: ServerUnaryCall<ListProductsRequest, ListProductsResponse>,
callback: sendUnaryData<ListProductsResponse>
) {
callback({ code: status.UNIMPLEMENTED }, null);
}
return {
createProduct,
getProduct,
listProducts,
};
}
我们将在main.ts
文件中导入getProductServer
功能和产品服务定义,然后将产品服务添加到GRPC服务器中。
src/main.ts
import "reflect-metadata";
import dataSource from "./db";
import { Server, ServerCredentials } from "@grpc/grpc-js";
import { getProductServer } from "./server";
import { ProductServiceService } from "@nodejs-microservices/protos/dist/product/product";
const server = new Server();
const HOST = process.env.HOST || "0.0.0.0";
const PORT = Number(process.env.PORT) || 50051;
const address = `${HOST}:${PORT}`;
dataSource
.initialize()
.then((db) => {
server.addService(ProductServiceService, getProductServer(db));
server.bindAsync(
address,
ServerCredentials.createInsecure(),
(error, port) => {
if (error) {
throw error;
}
console.log("server is running on", port);
server.start();
}
);
})
.catch((error) => console.log(error));
运行服务器后,我们将使用grpcurl
测试服务方法。阅读有关grpcurl
here
让我们测试CreateProduct
方法,我们需要将proto文件传递给grpcurl
命令。我正在从monorepo的根部调用grpcurl
,因此,原始文件的路径是对根的亲戚。之后,我们需要将请求主体作为JSON字符串传递。然后将地址传递到服务器和服务方法名称。
grpcurl -import-path packages/protos/src -proto product/product.proto -d '{ "name": "product 1", "description": "foo bar", "image": "", "tags": ["tag 1"]}' -plaintext localhost:50051 product.ProductService.CreateProduct
ERROR:
Code: Unimplemented
Message: Unknown Error
运行grpcurl
调用后,我们应该从服务器中获得错误。在这里,我们按照我们的预期获取状态代码Unimplemented
。
让我们为product
型号添加controller
。我们将在src/controllers
目录中创建product.controller.ts
文件。在此文件中,我们将编写数据库操作的逻辑。我们创建createProduct
函数,该函数接受数据库实例,并作为参数请求主体。
我们在这里定义了createProductReq
的接口,我们还可以使用protos
软件包中的CreateProductRequest
而不是此。当我们使用Product
型号字段映射请求字段时,我的个人偏爱是定义服务器内部的接口。因此,如果将来有CreateProductRequest
的变化,那么我们可以轻松地将CreateProductRequest
转换为createProductReq
src/controllers/product.controller.ts
import { DataSource } from "typeorm";
import { Product } from "../models/product";
interface createProductReq {
name: string;
description: string;
image: string;
tags: string[];
}
export const createProduct = async (
db: DataSource,
req: createProductReq
): Promise<Product> => {
const productRepository = db.getRepository(Product);
const product = new Product();
product.name = req.name;
product.description = req.description;
product.image = req.image;
product.tags = req.tags;
return productRepository.save(product);
};
然后,我们在server.ts
文件中导入ProductController
,然后更新createProduct
方法。我们将通过将结果传递到fromJSON
方法将结果从数据库插入操作转换为Product
消息。我们将将此逻辑包装在一个trycatch块中,并在错误的情况下返回状态代码INTERNAL
。
src/server.ts
import { sendUnaryData, ServerUnaryCall, status } from "@grpc/grpc-js";
import {
CreateProductRequest,
CreateProductResponse,
GetProductRequest,
GetProductResponse,
ListProductsRequest,
ListProductsResponse,
Product,
ProductServiceServer,
} from "@nodejs-microservices/protos/dist/product/product";
import { DataSource } from "typeorm";
import * as ProductController from "./controllers/product.controller";
export function getProductServer(db: DataSource): ProductServiceServer {
async function createProduct(
call: ServerUnaryCall<CreateProductRequest, CreateProductResponse>,
callback: sendUnaryData<CreateProductResponse>
) {
try {
const product = await ProductController.createProduct(db, call.request);
const productPB = Product.fromJSON(product);
const response: CreateProductResponse = {
product: productPB,
};
callback(null, response);
} catch (err) {
callback({ code: status.INTERNAL }, null);
console.error(err);
}
}
async function getProduct(
call: ServerUnaryCall<GetProductRequest, GetProductResponse>,
callback: sendUnaryData<GetProductResponse>
) {
callback({ code: status.UNIMPLEMENTED }, null);
}
async function listProducts(
call: ServerUnaryCall<ListProductsRequest, ListProductsResponse>,
callback: sendUnaryData<ListProductsResponse>
) {
callback({ code: status.UNIMPLEMENTED }, null);
}
return {
createProduct,
getProduct,
listProducts,
};
}
让我们再次使用grpcurl
测试CreateProduct
方法。我们现在应该从服务器中返回响应,而不是状态UNIMPLEMENTED
错误。
grpcurl -import-path packages/protos/src -proto product/product.proto -d '{ "name": "product 1", "description": "foo bar", "image": "", "tags": ["tag 1"]}' -plaintext localhost:50051 product.ProductService.CreateProduct
{
"product": {
"id": 1,
"name": "product 1",
"description": "foo bar",
"tags": [
"tag 1"
],
"createdAt": "2023-03-29T00:07:23.426Z",
"updatedAt": "2023-03-29T00:07:23.426Z"
}
}
让我们将listProducts
和getProduct
功能添加到产品控制器中。这两个函数都将接受数据库实例作为类似于createProduct
函数的参数
src/controllers/product.controller.ts
import { DataSource } from "typeorm";
import { Product } from "../models/product";
interface createProductReq {
name: string;
description: string;
image: string;
tags: string[];
}
export const createProduct = async (
db: DataSource,
req: createProductReq
): Promise<Product> => {
const productRepository = db.getRepository(Product);
const product = new Product();
product.name = req.name;
product.description = req.description;
product.image = req.image;
product.tags = req.tags;
return productRepository.save(product);
};
export const listProducts = async (db: DataSource): Promise<Product[]> => {
const productRepository = db.getRepository(Product);
return productRepository.find();
};
export const getProduct = async (
db: DataSource,
id: number
): Promise<Product | null> => {
const productRepository = db.getRepository(Product);
return productRepository.findOneBy({ id: id });
};
我们将在server.ts
文件中更新getProduct
和listProducts
方法。
在getProduct
方法中,我们将检查数据库是否返回product
,然后只返回响应。否则,我们将返回带有消息的状态代码NOT_FOUND
。
在listProducts
方法中,我们将从数据库中获取product
对象数组,我们将运行map
函数以将它们转换为Product
消息。
src/server.ts
import { sendUnaryData, ServerUnaryCall, status } from "@grpc/grpc-js";
import {
CreateProductRequest,
CreateProductResponse,
GetProductRequest,
GetProductResponse,
ListProductsRequest,
ListProductsResponse,
Product,
ProductServiceServer,
} from "@nodejs-microservices/protos/dist/product/product";
import { DataSource } from "typeorm";
import * as ProductController from "./controllers/product.controller";
export function getProductServer(db: DataSource): ProductServiceServer {
async function createProduct(
call: ServerUnaryCall<CreateProductRequest, CreateProductResponse>,
callback: sendUnaryData<CreateProductResponse>
) {
try {
const product = await ProductController.createProduct(db, call.request);
const productPB = Product.fromJSON(product);
const response: CreateProductResponse = {
product: productPB,
};
callback(null, response);
} catch (err) {
callback({ code: status.INTERNAL }, null);
console.error(err);
}
}
async function getProduct(
call: ServerUnaryCall<GetProductRequest, GetProductResponse>,
callback: sendUnaryData<GetProductResponse>
) {
try {
const product = await ProductController.getProduct(db, call.request.id);
if (product) {
const productPB = Product.fromJSON(product);
const response: GetProductResponse = {
product: productPB,
};
callback(null, response);
} else {
callback(
{
code: status.NOT_FOUND,
message: `Product ${call.request.id} not found`,
},
null
);
}
} catch (err) {
callback({ code: status.INTERNAL }, null);
console.error(err);
}
}
async function listProducts(
call: ServerUnaryCall<ListProductsRequest, ListProductsResponse>,
callback: sendUnaryData<ListProductsResponse>
) {
try {
const products = await ProductController.listProducts(db);
const productsPB = products.map(Product.fromJSON);
const response: ListProductsResponse = {
products: productsPB,
};
callback(null, response);
} catch (err) {
callback({ code: status.INTERNAL }, null);
console.error(err);
}
}
return {
createProduct,
getProduct,
listProducts,
};
}
我们将使用grpcurl
测试ListProducts
和GetProduct
方法。他们不应该返回状态UNIMPLEMENTED
的错误。
grpcurl -import-path packages/protos/src -proto product/product.proto -plaintext localhost:50051 product.ProductService.ListProducts
{
"products": [
{
"id": 1,
"name": "product 1",
"description": "foo bar",
"tags": [
"tag 1"
],
"createdAt": "2023-03-29T00:07:23.426Z",
"updatedAt": "2023-03-29T00:07:23.426Z"
}
]
}
grpcurl -import-path packages/protos/src -proto product/product.proto -d '{"id": 1}' -plaintext localhost:50051 product.ProductService.GetProduct
{
"product": {
"id": 1,
"name": "product 1",
"description": "foo bar",
"tags": [
"tag 1"
],
"createdAt": "2023-03-29T00:07:23.426Z",
"updatedAt": "2023-03-29T00:07:23.426Z"
}
}
创建GRPC客户端
让我们创建一个客户端来测试product-service
。我们将创建一个简单的程序,该程序将连接到服务器,进行呼叫并在控制台中记录响应。
从仓库的根中,我们将运行npm init
命令来脚克式包装。在test-client
中,我们将创建src
和dist
目录。
npm init -w services/test-client --scope=@nodejs-microservices -y
cd services/test-client/
mkdir src dist
我们将为客户端安装依赖关系和DEV依赖关系。我们将安装@nodejs-microservices/protos
软件包以获取客户端存根。
npm i -D typescript ts-node
npm i @grpc/grpc-js @nodejs-microservices/protos
我们将在test-client
中创建tsconfig.json
文件,tsconfig.json
与product-service
tsconfig.json
{
"compilerOptions": {
"target": "es2016",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
我们将在package.json
文件中更新scripts
以运行并构建客户端。
package.json
{
...
"scripts": {
"dev": "ts-node src/main.ts",
"build": "tsc",
"start": "node dist/main.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
...
}
让我们在src
目录中创建main.ts
文件。我们将创建一个主函数并分别测试每个服务方法。
首先,我们需要从grpc-js
软件包和ProductServiceClient
从我们的protos
软件包导入credentials
对象。然后,我们将创建一个GRPC客户端。在创建客户端实例时,我们需要传递服务器URL和身份验证凭据。 product-service
没有任何身份验证
创建客户端实例后,我们将调用服务方法名称。在这里,我们在客户端上调用createProduct
方法。我们将创建呼叫的请求对象,然后使用请求对象和回调函数调用该方法。
src/main.ts
import { credentials } from "@grpc/grpc-js";
import {
CreateProductRequest,
ProductServiceClient,
} from "@nodejs-microservices/protos/dist/product/product";
const PRODUCT_SERVICE_URL = process.env.USER_SERVICE_URL || "0.0.0.0:50051";
function main() {
const client = new ProductServiceClient(
PRODUCT_SERVICE_URL,
credentials.createInsecure()
);
const req: CreateProductRequest = {
name: "test product",
description: "foo bar",
image: "https://example.com/image",
tags: ["tag-1"],
};
client.createProduct(req, (err, resp) => {
if (err) {
console.error(err);
} else {
console.log("Response :", resp);
}
});
}
main();
我们可以类似地调用并测试getProduct
和listProducts
方法。我在此博客中跳过了那部分。
此博客的所有源代码均可在GitHub上找到。