使用GRPC和Typescript的Nodejs微服务
#typescript #node #microservices #grpc

对于工程团队来说,很难管理一个非常大的应用程序。因此,公司没有构建大型整体应用程序,而是编写了多个可以由多个小型团队管理的微服务。这些微服务与彼此之间相互松散,可以独立开发,部署和缩放。但是,在这种微服务体系结构中,微服务通常需要通过轻量级通信协议相互通信。对于这种过程间的交流,GRP​​C是开发人员的流行选择。

GRPC是用于远程过程调用的开源框架。它由Google开发。它是一个轻巧,高性能和类型的安全框架,用于服务间通信。它使用HTTP/2进行运输和协议缓冲区来序列化结构化数据。

在此博客中,我们将使用nodejs编写微服务,并使用grpc进行交流。

回购设置

我们将创建一个monorepo,我们将保留所有微服务和Protobuf文件。我们将拥有一个名为product-service的服务器和一个客户端来测试服务器。

让我们创建我们的项目回购并将当前的工作目录更改为它。

mkdir nodejs-microservices
cd nodejs-microservices

我们将在其中创建一个空节点项目。在这里,我们将通过-y标志接受所有默认值。
我们将使用npm workspace进行本地共享依赖项分辨率。

npm init -y

我们将创建2个目录packagesservices。在packages目录中,我们将保留所有可重复使用的库,例如Protobuf等生成的打字稿文件,在services目录中,我们将保留所有的微服务。

mkdir packages services

让我们首先创建protos软件包。

注意:这不是使用Protobuf文件的理想方式,因为该版本未锁定在服务中。因此,我们在Protos软件包中所做的任何更改都将在服务中自动反思。最好将Protobuf文件保存在单独的存储库中,并将生成的打字稿文件发布到注册表中。并将其用作固定版本
的NPM软件包

npm init -w packages/protos --scope=@nodejs-microservices -y

protos软件包中,我们将创建2个目录srcdist。在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方法CreateProductGetProductListProducts

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-serviceproduct-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函数,该文件将返回使用createProductgetProductlistProducts方法的对象。我们正在使用此服务器对象而不是由于服务器签名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"
  }
}

让我们将listProductsgetProduct功能添加到产品控制器中。这两个函数都将接受数据库实例作为类似于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文件中更新getProductlistProducts方法。

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测试ListProductsGetProduct方法。他们不应该返回状态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中,我们将创建srcdist目录。

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.jsonproduct-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();

我们可以类似地调用并测试getProductlistProducts方法。我在此博客中跳过了那部分。

此博客的所有源代码均可在GitHub上找到。