使用Node.js,Typescript和Connect构建现代GRPC供电的微服务
#教程 #typescript #node #prisma

2023年的微服务

微服务架构不是新的(1)。相反,这是一个写得很好的话题,对其权衡的探索很深(2)。许多人说您不需要它们(3)并写下他们的伤害。一些公司甚至从微服务迁移到了整体(4),但许多成功的公司仍在实施这种模式。我可能在马丁·福勒(Martin Fowler)的阿比亚奇(Abiaoqian)5营中,即有一个微服务的地方,但是你们俩都不应该从那里开始,等到您确定了对它们的真正需求。我们从DOPT开始了一个巨石,因为它是最简单的架构,它使我们能够迅速建立和迭代,就像我们向早期客户学到的那样。随着时间的流逝,我们将应用程序中的碎片分为自己的服务。

本文旨在超越现在频繁的微服务上的免责声明,旨在分享我们在DOPT上使用Node.js,Typescript和Connect在DOPT上建立内部GRPC驱动的微服务的经验。该内部服务旨在支持与DOPT与分析相关的用例。在这种情况下,我们并不是从整体中突破现有的实现,但是从一开始就可以为此功能构建微服务。由于以下内容,建立微服务以支持我们在这里的需求是很有意义的。

  • 此服务中的数据与其他服务中的数据松散耦合,即跨服务交易的低风险
  • 此服务的存储要求可能会随着时间的流逝而发展,可能会远离关系数据库

node.js中的grpc驱动API

在我们潜水之前,请在DOPT上进行一些tl; dr。DOPT本质上是一个用于在画布上设计用户用户状态机的Web应用程序,并与API和SDK配对,可在运行时使用这些机器。想法是,您可以根据产品的用户实例化这些机器。我们让您通过计算机来进步用户,并为您处理用户状态的持久性。您可以迭代并为您的机器版本版本,我们将处理跨计算机版本的用户。这应该足够深,以使本文中的任何特定于DOPT特定的位都有背景化(但是,如果您对深入研究感兴趣,可以查看我们的docs)。

在开始构建此服务时,我们想将GRPC用于其API。当构建API到目前为止,我们一直在休息,主要是出于必要的,即我们的公共API需要自动生成的客户SDK和文档,以便与他们合作的开发人员。我们使用FastifyTypebox构建了这些API,但感觉到生成OpenAPI规格的代码优先方法燃烧了。我会为您提供详细信息,并为另一篇文章节省这种经验/学习。可以说我们喜欢GRPC的模式优先的方法。 This博客文章总结了我们的感受

既然我们正在建立内部服务,那么我们在设计API方面拥有更大的自由度。 GRPC是内部服务的绝佳选择,但在Node.js中构建GRPC驱动的API是一次冒险。 GRPC的许多工具和框架都是针对后端上使用的语言,例如Java,Go等。因此,使用JavaScript(或Typescript,than Chistions)的开发人员并不是目标受众。

Connect在这方面是游戏规则改变者。查看他们的博客,尤其是Connect: a better gRPCAPI design is stuck in the past(上面也引用)。我们很大程度上是一家打字商商店,并且不觉得这项服务需要使用任何其他语言,因此Connect感觉就像是对构建GRPC驱动的API的一流支持,我们在DOPT上使用的技术。

接下来是关于我们如何开发GRPC驱动服务的教程风格的帖子。

入门

您需要在计算机上安装的pnpmNode.js +一些用于切换节点版本的工具(例如fnmnvm可以正常工作);

>

本文中的所有代码示例均来自以下存储库。帖子的每个部分都应在存储库的主分支中具有相应的commit

您也可以克隆并建立最终结果并以此方式遵循。

git clone git@github.com:dopt/building-a-node-microservice.git;

cd building-a-node-microservice;

fnm use; # or `nvm use` - You should see a message like "Using Node v18.xx.xx" after running this successfully.

pnpm install;
pnpm run build;

第1部分:创建MonorePo(9b91734

我将示例存储库作为monorepo结构。虽然与GRPC驱动的微服务的教程无关紧要,但设置为

  • 我们在dopt上使用和喜欢的设置
  • 展示现代MonorePo工具,因此可能对读者有用

您将在整个教程中看到,MonorePo结构还促进了我们将问题分解为单独但已包裹的零件。到本教程结束时,我们将拥有五个不同的软件包/模块,每个软件包/模块都有独特的责任。他们的关系看起来像这样。

state-transitions-dependencies

我将在此Monorepo中使用pnpmturborepo

我们将首先初始化存储库和安装涡轮增压。

pnpm init;
pnpm add turbo --save-dev --ignore-workspace-root-check;

我将要删除main字段,更新scripts并添加packageManger字段。最终结果看起来像这样。

{
  "name": "build-a-node-microservice",
  "version": "1.0.0",
  "scripts": {
    "build": "pnpm exec turbo run build",
    "clean": "pnpm run --parallel -r clean",
    "format": "pnpm run --parallel -r format",
    "lint": "pnpm exec turbo run lint",
    "test": "pnpm exec turbo run test",
    "typecheck": "pnpm exec turbo run typecheck",
    "uninstall": "pnpm -r exec rm -rf node_modules"
  },
  "packageManager": "pnpm@8.2.0",
  "devDependencies": {
    "turbo": "1.8.8"
  }
}

PNPM和Turbo都需要一些配置。

PNPM通过pnpm workspaces对MonorePos进行了内置支持。我们将配置pnpm-workspace.yaml,如下所示。

packages:
  # all packages in subdirectories of services/
  - "services/**"

这定义了workspace的根源,并约束工作区中哪些目录可以容纳包装。我们的软件包管理器(pnpm)和我们的构建工具(turbo)将扫描这些目录并查找package.json文件,表明包裹。软件包的依赖性用于定义工作区的拓扑。

turbo configuration将与上面的软件包脚本相关,因为我们将在上面的每个软件包脚本中创建一个管道任务。每个管道任务的配置表明它是否取决于工作区的拓扑依赖性,可缓存以及任务将在何处输出构建工件。我们的配置看起来像这样。

{
  "$schema": "https://turborepo.org/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [
        "dist/**"
      ]
    },
    "test": {
      "dependsOn": [],
      "outputs": []
    },
    "clean": {
      "cache": false
    },
    "typecheck": {
      "outputs": []
    },
    "format": {
      "outputs": []
    },
    "lint": {
      "outputs": []
    },
  }
}

创建一个README.md.gitinore,如下所示

echo "# Building a modern gRPC-powered microservice using Node.js, Typescript, and Connect" >> README.md
node_modules
# where we will output build artifacts 
dist/
# where turbo caches the output of tasks
.turbo

然后安装并构建monorepo以确认一切正常。

pnpm install;
pnpm run build;

第2部分:脚手架(dc00d46

我们的工作区配置表明我们的服务将生活在服务的子目录中。

我将以共享的scope居住在一个包裹家庭中的服务来建模我们的服务。这些包裹不会出版以说范围(除非该服务是公开的,并且您拥有该范围),但是情绪是相同的。我们的这项服务范围将是@state-transitions

mkdir -p services/@state-transitions
cd $_;

我们将首先创建一个软件包,该软件包将容纳Protobuf定义的服务和逻辑,以从架构中生成打字稿代码。

让我们创建该软件包,初始化package.json并添加必要的构建工具。

mkdir definition;
cd definition;

pnpm init
pnpm add -D unbuild;

另外,让我们删除源代码,以便我们可以确认一切都在起作用。

mkdir src;
echo "export {};" >> src/index.ts

特别是对package.json的更新,特别是更新

  • 包含范围的软件包名称
  • 描述(因为文档总是有帮助的)
  • 脚本以实现buildclean,同时用有用的消息使其余的脚本

结果如下。

{
  "name": "@state-transitions/definition",
  "version": "0.0.0",
  "description": "The state transitions service definition",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/index.cjs",
      "import": "./dist/index.mjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "👇required package scripts": "",
    "build": "unbuild;",
    "clean": "rm -rf ./dist",
    "test": "echo \"@state-transitions/definition does not require test\"; exit 0;",
    "format": "echo \"@state-transitions/definition does not require test\"; exit 0;",
    "lint": "echo \"@state-transitions/definition does not require test\"; exit 0;",
    "☝️ required package scripts": ""
  },
  "dependencies": {},
  "devDependencies": {
    "unbuild": "1.2.0"
  }
}

在这一点

pnpm run build

第3部分:设计服务模式,Codegen(35f48e4

如介绍中所述,我们将使用BufConnect作为我们的工具。我们将从安装依赖项开始。

 # dependencies
pnpm add @bufbuild/protobuf

# devDependencies
pnpm add -D @bufbuild/buf @bufbuild/protoc-gen-connect-es @bufbuild/protoc-gen-es;

buf提供了一个功能强大的CLI,用于使用Protobuf定义。我们将在下面直接使用CLI,但是在本指南中,我们将其用法隐藏在软件包脚本内部的一个常见界面后面,因此我们的任务管道与这些实现详细信息无关。

首先,我们将在定义软件包的根部初始化BUF模块。

pnpm exec buf mod init

这将在下面创建buf.yaml文件,并向BUF发出信号,表明该软件包中的原始文件应视为逻辑单元。

 version: v1
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT

我们需要告诉buf从其在此模块中发现的原始文件中生成的内容。这是通过buf.gen.yaml文件实现的,该文件配置了将通过模块运行的各种protoc插件,并指定了它们将输出生成的代码的位置。

我们想要两件事,即

的打字稿定义

首先,我们将创建一个buf.gen.yaml

touch buf.gen.yaml

然后用上述插件填充它。

version: v1
managed:
  enabled: true
plugins:
  - name: es
    opt: target=ts
    out: src
  - name: connect-es
    opt: target=ts
    out: src

两个插件的out:配置表明我们将生成的代码输出到SRC目录中。想法是,我们输出的代码是打字稿。我们将需要构建该打字稿,并输出编译的JavaScript以及类型定义。从这个意义上讲,它是该软件包的来源,尽管生成了。

proto定义将生活在与其package字段相一致的目录层次结构中,该字段与API进化和后退兼容性有关。有关here的更多信息。我们的包装字段将是

package proto.transitions.v1

因此,该原始文件的文件路径应反映出例如

mkdir -p proto/transitions/v1
cd $_;

最后,我们可以踩踏原始文件。最初,它将具有三个RPC

  • 斯塔特术
    • 与状态转换服务记录状态过渡
  • getStateTransition
    • 从国家过渡服务中获得国家过渡
  • HealthCheck
    • 返回服务状态

这些将使我们能够记录用户状态机的过渡,获取单个过渡,并检查服务是否启动并运行(无论是手动还是在K8中使用就绪探针)。这组RPC实际上只是一个起点。当我们为用户构建分析功能时,我们将开始公开支持这些用例的RPC。从附加的角度来看,我们将被迫考虑到Gayeway中服务定义中存在的RPC,这也是GRPC驱动的服务。在另一篇文章中提供了更多信息!

以下是定义RPC及其请求/响应类型的第一个刺伤。一些特定于DOPT的细节,我们的机器版本是版本的,因此我们记录的状态过渡将是(用户,块,版本,过渡)元组。

syntax = "proto3";

package proto.transitions.v1;

import "google/protobuf/timestamp.proto";

enum ResponseStatus {
  RESPONSE_STATUS_ACCEPTED_UNSPECIFIED = 0;
  RESPONSE_STATUS_REJECTED = 1;
}

message StateTransitionRequest {
  string user = 1;
  string block = 2;
  uint32 version = 3;
  string transition = 4;
  google.protobuf.Timestamp timestamp = 5;
}

message StateTransitionResponse {
  ResponseStatus status = 1;
}

message GetStateTransitionRequest {
  string user = 1;
  string block = 2;
  uint32 version = 3;
}

message GetStateTransitionResponse {
  string user = 1;
  string block = 2;
  uint32 version = 3;
  string transition = 4;
  google.protobuf.Timestamp timestamp = 5;
}

message HealthCheckRequest {}

message HealthCheckResponse {
  enum ServingStatus {
    SERVING_STATUS_UNKNOWN_UNSPECIFIED = 0;
    SERVING_STATUS_SERVING = 1;
    SERVING_STATUS_NOT_SERVING = 2;
  }
  ServingStatus status = 1;
}

service EventLogService {
  rpc StateTransition(StateTransitionRequest) returns (StateTransitionResponse) {}
  rpc GetStateTransition(GetStateTransitionRequest) returns (GetStateTransitionResponse) {}
  rpc HealthCheck(HealthCheckRequest) returns(HealthCheckResponse) {}
}

我们可以使用BUF CLI如下为本模块生成代码

pnpm exec buf generate

此将代码输出到./src/proto/transitions/v1/目录中,镜像输出目录层次结构中的软件包路径。由于此输出的目的地和内容是基于软件包字段和插件配置的静态知名的,因此我们可以安全地创建一个桶形文件,以导出这两个生成的文件的内容。这将使构建包装更容易,更干净。

export * from "./proto/transitions/v1/state-transitions_connect";
export * from "./proto/transitions/v1/state-transitions_pb";

由于我们的./src目录包含生成的代码,因此我们需要为此软件包创建一个有点不寻常的.gitignore

dist/
src/*
!src/index.ts

此外,我们需要更新软件包脚本以正确构建和清洁。对于此软件包而言,建筑是一个两步的过程,涉及代码生成,然后构建所述生成的代码。此外,我们的clean脚本需要考虑被倾倒到./src目录中的生成的代码。现在我们必须编写代码来防御与该决定相关的潜在不良结果。

diff --git a/services/@state-transitions/definition/package.json b/services/@state-transitions/definition/package.json
index 1c2a0ea..7b89dd6 100644
--- a/services/@state-transitions/definition/package.json
+++ b/services/@state-transitions/definition/package.json
@@ -18,12 +18,13 @@
   ],
   "scripts": {
     "👇required package scripts": "",
-    "build": "unbuild;",
-    "clean": "rm -rf ./dist",
+    "build": "pnpm run generate; unbuild;",
+    "clean": "rm -rf ./dist index",     
     "format": "echo \"@state-transitions/definition does not require test\"; exit 0;",
     "lint": "echo \"@state-transitions/definition does not require test\"; exit 0;",
     "test": "echo \"@state-transitions/definition does not require test\"; exit 0;",
-    "☝️ required package scripts": ""
+    "☝️ required package scripts": "",
+    "generate": "buf generate"
   },
   "dependencies": {
     "@bufbuild/protobuf": "1.2.0"

此时,我们应该能够成功构建定义包。

pnpm run build

随着我们迭代定义,我们将希望获得更好的开发人员体验,以重建更改包装。通常,对于图书馆或实用风格的包装,我可以触及unbuild’s stub concept或使用esbuild/tsup/rollup来实现更传统的手表/重建,但是在这种情况下,我是m观看一个源于来源的原始文件,这破坏了这些工具的假设。

考虑到这一点,我将达到可信赖的ol-nodemon。我有信心NPM趋势会让我震惊,说Steed并将我引导到一些热门的新套餐,但是考虑到这在更广泛的项目中所扮演的角色很少。

pnpm add -D nodemon

添加nodemon后,我们可以将dev脚本接线以配置其用法,即观看proto dir并调用build包脚本。

diff --git a/services/@state-transitions/definition/package.json b/services/@state-transitions/definition/package.json
index 7b89dd6..45979dc 100644
--- a/services/@state-transitions/definition/package.json
+++ b/services/@state-transitions/definition/package.json
@@ -20,6 +20,7 @@
     "👇required package scripts": "",
     "build": "pnpm run generate; unbuild;",
     "clean": "rm -rf ./dist ./src/proto",
+    "dev": "nodemon -e proto --watch proto/ --exec \"pnpm run build\"",
     "test": "echo \"@state-transitions/definition does not require test\"; exit 0;",
     "format": "echo \"@state-transitions/definition does not require test\"; exit 0;",
     "lint": "echo \"@state-transitions/definition does not require test\"; exit 0;",
@@ -33,6 +34,7 @@
     "@bufbuild/buf": "1.15.0-1",
     "@bufbuild/protoc-gen-connect-es": "0.8.6",
     "@bufbuild/protoc-gen-es": "1.2.0",
+    "nodemon": "2.0.22",
     "typescript": "5.0.4",
     "unbuild": "1.2.0"
   }

buf提供了一些出色的工具,用于编写标准和自以为是的原始文件。我要将他们的cli linter和formatter连接到我们的软件包脚本中,以便在此软件包的上下文中进行格式化代码和伸长代码的任务管道。

diff --git a/services/@state-transitions/definition/package.json b/services/@state-transitions/definition/package.json
index 7836889..1a343e0 100644
--- a/services/@state-transitions/definition/package.json
+++ b/services/@state-transitions/definition/package.json
@@ -22,8 +22,8 @@
     "clean": "rm -rf ./dist ./src/proto",
     "dev": "nodemon -e proto --watch proto/ --exec \"pnpm run build\"",
     "test": "echo \"@state-transitions/definition does not require test\"; exit 0;",
-    "format": "echo \"@state-transitions/definition does not require test\"; exit 0;",
-    "lint": "echo \"@state-transitions/definition does not require test\"; exit 0;",
+    "format": "buf format -w",
+    "lint": "buf lint",
     "☝️ required package scripts": "",
     "generate": "buf generate"

我们可以通过从工作区根运行以下内容来确认这些脚本已连接到我们的构建管道中。

pnpm run build; pnpm run format; pnpm run lint; pnpm run test;

第4部分:实施服务(8788740

我经常发现,使用强大的语言工作意味着我在软件设计阶段花费的时间要比实际实现要多得多。同样的故事在这里播放,并且反映在实施服务实施和运行的容易之中。感觉更像是遵循定义明确的指南,而不是从头开始。

好吧,让我们开始!我们将首先在服务范围中创建服务包并初始化它。

mkdir service;
cd service;
pnpm init;

我们将编辑与上一部分相同的package.json,以在名称中包含范围,以在软件包脚本中包含有用的运行时消息并定义其导出。看起来像这样。

{
  "name": "@state-transitions/service",
  "version": "0.0.0",
  "description": "The state transitions service",
  "type": "module",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "👇required package scripts": "",
    "build": "echo \"@state-transitions/service `dev` not implemented\"; exit 0;",
    "clean": "echo \"@state-transitions/service `dev` not implemented\"; exit 0;",
    "dev": "echo \"@state-transitions/service `dev` not implemented\"; exit 0;",
    "format": "echo \"@state-transitions/service `format` not implemented\"; exit 0;",
    "lint": "echo \"@state-transitions/service `lint` not implemented\"; exit 0;",
    "test": "echo \"@state-transitions/service `test` not implemented\"; exit 0;",
    "typecheck": "tsc --noEmit",
    "☝️ required package scripts": ""
  },
  "dependencies": {},
  "devDependencies": {}
}

我们将使用fastify作为此微服务的网络框架。

pnpm add fastify;
mkdir src;
cd src;
touch index.ts;

我们曾经使用unbuild构建。

pnpm add -D unbuild;
pnpm run build;

以下是package.json的更新。

diff --git a/services/@state-transitions/service/package.json b/services/@state-transitions/service/package.json
index 317277e..9354855 100644
--- a/services/@state-transitions/service/package.json
+++ b/services/@state-transitions/service/package.json
@@ -16,15 +16,20 @@
   ],
   "scripts": {
     "👇required package scripts": "",
-    "build": "echo \"@state-transitions/service `dev` not implemented\"; exit 0;",
-    "clean": "echo \"@state-transitions/service `dev` not implemented\"; exit 0;",
+    "build": "unbuild",
+    "clean": "rm -rf ./dist",
     "dev": "echo \"@state-transitions/service `dev` not implemented\"; exit 0;",
     "format": "echo \"@state-transitions/service `format` not implemented\"; exit 0;",
     "lint": "echo \"@state-transitions/service `lint` not implemented\"; exit 0;",
     "test": "echo \"@state-transitions/service `test` not implemented\"; exit 0;",
     "typecheck": "tsc --noEmit",
-    "☝️ required package scripts": ""
+    "☝️ required package scripts": "",
+    "start": "node ./dist/index.mjs"
   },
-  "dependencies": {},
-  "devDependencies": {}
+  "dependencies": {
+    "fastify": "4.15.0"
+  },
+  "devDependencies": {
+    "unbuild": "1.2.0"
+  }
 }

,不要立即实现服务定义,而是让快速服务器启动并运行,并确认此设置,直到这一点是正确的。像以下小型服务器实现一样。

import { fastify } from "fastify";

const server = fastify();

server.get("/health-check", () => {
  return {
    status: 200,
  };
});

await server.listen({
  host: "localhost",
  port: 8080,
});

然后,我们可以在@state-transitions/service软件包根中运行以下内容。


pnpm run build;
node ./dist/index.mjs

在另一个窗口中,我们可以卷曲我们创建的简单/health-check端点。

$ curl http://localhost:8080/health-check | jq
{
  "status": 200
}

好吧,既然我们知道该软件包的设置正确,请实现第3部分中创建的服务定义。为此,我们将需要一些与连接相关的依赖关系 +对定义本身的依赖关系。


pnpm add @bufbuild/connect @bufbuild/connect-fastify
pnpm add @state-transitions/definition;

我们将开始更新服务器索引文件以注册@bufbuild/connect-fastify插件。

diff --git a/services/@state-transitions/service/src/index.ts b/services/@state-transitions/service/src/index.ts
index 666f1dd..55d9d56 100644
--- a/services/@state-transitions/service/src/index.ts
+++ b/services/@state-transitions/service/src/index.ts
@@ -1,11 +1,12 @@
 import { fastify } from "fastify";
+import { fastifyConnectPlugin } from "@bufbuild/connect-fastify";
+
+import routes from "./connect";

 const server = fastify();

-server.get("/health-check", () => {
-  return {
-    status: 200,
-  };
+server.register(fastifyConnectPlugin, {
+  routes,
 });

await server.listen({

上面,我从尚未存在的相对位置的连接文件中导入routes。让我们创建它并像这样填充它。

import { ConnectRouter } from "@bufbuild/connect";
import {
  GetStateTransitionRequest,
  HealthCheckResponse_ServingStatus,
  ResponseStatus,
  StateTransitionRequest,
  StateTransitionService,
} from "@state-transitions/definition";

export default (router: ConnectRouter) => {
  router.service(StateTransitionService, {
    stateTransition(_: StateTransitionRequest) {
      return {
        status: ResponseStatus.ACCEPTED_UNSPECIFIED,
      };
    },
    getStateTransition(request: GetStateTransitionRequest) {
      return {
        user: request.user,
        block: request.block,
        version: request.version,
      };
    },
    healthCheck() {
      return {
        status: HealthCheckResponse_ServingStatus.SERVING,
      };
    },
  });
};

就是这样!为了确认一切都在起作用,我们可以启动服务并卷曲端点。

pnpm run start;
curl \             
  --header 'Content-Type: application/json' \
  --data {} \
  http://localhost:8080/proto.transitions.v1.StateTransitionService/HealthCheck
> {"status":"SERVING_STATUS_SERVING"}

curl \
  --header 'Content-Type: application/json' \
  --data '{ "user": "9fke93ur23-1", "block": "394208feop12e", "version": 0, "transition": "next", "timestamp": "1099-10-21T07:52:58Z" }' \
  http://localhost:8080/proto.transitions.v1.StateTransitionService/StateTransition
> {}

curl \
  --header 'Content-Type: application/json' \
  --data '{ "user": "9fke93ur23-1", "block": "394208feop12e", "version": 1 }' \
  http://localhost:8080/proto.transitions.v1.StateTransitionService/GetStateTransition
> {"user":"9fke93ur23-1","block":"394208feop12e","version":1}

第5部分:测试服务(7b52d74

我们得到了最初的RPC;让我们尝试添加一些测试!

当我们在上一部分中向curl提出请求以确认一切都在起作用时,我理想地喜欢使用客户端进行测试。

首先,我将为客户端创建一个软件包,以便我们的实际用法和测试用法共享相同的客户端实现并避免复制代码。在@state-transitions目录中,我将运行以下内容。

mkdir client;
cd $_;
pnpm init;

我知道我们需要一些依赖项,其中最重要的是@state-transitions/definition,我们将使用它来创建正确键入的客户端。

 pnpm add @state-transitions/definition;

我们还需要连接依赖项

pnpm add  @bufbuild/connect @bufbuild/connect-node @bufbuild/protobuf; # connect deps

我们的客户源代码将是超级小的。

import { StateTransitionService } from "@state-transitions/definition";
import { createConnectTransport } from "@bufbuild/connect-node";
import { createPromiseClient } from "@bufbuild/connect";

// The following line is due to these issues  
// > https://github.com/aspect-build/rules_ts/issues/159#issuecomment-1437399901
// > https://github.com/microsoft/TypeScript/issues/47663#issuecomment-1270716220
import type {} from "@bufbuild/protobuf";

export const transport = createConnectTransport({
  baseUrl: `http://localhost:8080`,
  httpVersion: "1.1",
});

export const client = createPromiseClient(StateTransitionService, transport);

添加构建dep(例如unbuild)和最小化脚本后,我们的package.json看起来像这样

{
  "name": "@state-transitions/client",
  "version": "0.0.0",
  "description": "The state transitions client",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/index.cjs",
      "import": "./dist/index.mjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "👇required package scripts": "",
    "build": "unbuild",
    "clean": "rm -rf ./dist",
    "dev": "echo \"@state-transitions/service `dev` not implemented\"; exit 0;",
    "format": "echo \"@state-transitions/service `format` not implemented\"; exit 0;",
    "lint": "echo \"@state-transitions/service `lint` not implemented\"; exit 0;",
    "test": "echo \"@state-transitions/service `test` not implemented\"; exit 0;",
    "typecheck": "tsc --noEmit",
    "☝️ required package scripts": ""
  },
  "dependencies": {
    "@bufbuild/connect": "0.8.6",
    "@bufbuild/connect-node": "0.8.6",
    "@state-transitions/definition": "workspace:*",
    "@bufbuild/protobuf": "1.2.0"
  },
  "peerDependencies": {
    "@bufbuild/protobuf": "1.2.0"
  },
  "devDependencies": {
    "unbuild": "1.2.0"
  }
} 

和voilã,我们为客户创建了一个可以在测试中使用的软件包!

我要创建一个测试包来容纳测试可能并不奇怪。所有东西的包装!一边开玩笑,包装是封装高度凝聚力的绝妙方式。

@state-transitions目录中,我将运行以下内容。

mkdir tests;
cd $_;
pnpm init;

在这种情况下,依赖项很简单

  • @state-transitions/client向服务要求
  • @state-transitions/service,以便我们可以在测试中提出
  • fastify类型
  • vitest用于测试

添加这些deps并更新软件包脚本后,我们的package.json看起来像\

{
  "name": "@state-transitions/tests",
  "version": "0.0.0",
  "description": "The state transitions service integration tests",
  "type": "module",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import": "./dist/index.mjs"
    }
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "👇required package scripts": "",
    "build": "echo \"@state-transitions/tests build target is not needed.\"; exit 0;",
    "clean": "rm -rf ./dist",
    "dev": "echo \"@state-transitions/tests dev not implemented\"; exit 0;",
    "format": "echo \"@state-transitions/tests format not implemented\"; exit 0;",
    "lint": "echo \"@state-transitions/tests lint not implemented\"; exit 0;",
    "test": "vitest run ./src/__tests__/",
    "typecheck": "tsc --noEmit",
    "☝️ required package scripts": ""
  },
  "dependencies": {
    "@state-transitions/client": "workspace:*",
    "@state-transitions/service": "workspace:*"
  },
  "devDependencies": {
    "fastify": "4.15.0",
    "vitest": "0.30.1"
  }
}

是时候写一些测试了。

mkdir -p src/__tests__;
cd $_;
touch basic.test.ts

测试将用于

  • 等待服务出现
  • 使用客户端提出请求,并期望以某种方式看待响应
  • 等待服务旋转

第一张通行证看起来像这样

import { beforeAll, afterAll, describe, expect, it } from "vitest";

import { server } from "@state-transitions/service";
import { client } from "@state-transitions/client";
import { FastifyInstance } from "fastify";

describe("[Test] @state-transition/service", () => {
  let fastify: FastifyInstance;

  beforeAll(async () => {
    fastify = await server;
    await fastify.ready();
  });

  afterAll(async () => {
    await fastify.close();
  });
  //
  describe("client.healthCheck(...)", () => {
    it("should get correct response from clients RPC method", async () => {
      const response = await client.healthCheck({});
      expect(response).toEqual({
        status: 1,
      });
    });
  });
});

如果我们使用pnpm run test进行测试,我们会看到它通过!

$ pnpm run test

> @state-transitions/tests@0.0.0 test /home/joe/repos/blog-posts/building-a-node-microservice/services/@state-transitions/tests
> vitest run ./src/__tests__/

 RUN  v0.30.1 /home/joe/repos/building-a-node-microservice/services/@state-transitions/tests

 ✓ src/__tests__/basic.test.ts (1)

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  11:08:53
   Duration  652ms (transform 73ms, setup 0ms, collect 213ms, tests 28ms, environment 0ms, prepare 66ms)

第6部分:添加开发数据库(e3b1f42

此服务将拥有自己的数据库。该教程创建了一个Postgres数据库,该数据库在Docker容器中本地运行。这在开发方面很好,但是在生产环境中,您可能会在其中一个云中托管DB。两者之间的距离只是环境变量,我将留给读者的练习。

开始,我们将在@state-transitions范围中创建一个数据库目录。

mkdir database
cd database
pnpm init;

我们将像这样更新我们的package.json

{
  "name": "@state-transitions/database",
  "version": "0.0.0",
  "description": "The state transitions database",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "scripts": {
    "👇required package scripts": "",
    "build": "unbuild",
    "clean": "rm -rf ./dist",
    "dev": "echo \"@state-transitions/service 'dev' not implemented\"; exit 0;",
    "format": "echo \"@state-transitions/service 'format' not implemented\"; exit 0;",
    "lint": "echo \"@state-transitions/service 'lint' not implemented\"; exit 0;",
    "test": "echo \"@state-transitions/service 'test' not implemented\"; exit 0;",
    "typecheck": "tsc --noEmit",
    "☝️ required package scripts": ""
  }
}

由于我们将使用Prisma作为ORM,因此我们需要安装必要的依赖项并创建Prisma架构

pnpm add -D prisma;
pnpm add @prisma/client;
mkdir src;
touch src/schema.prisma

我们的Prisma模式将非常简单。一张状态过渡表。大致相当于日志线。我们在架构中配置数据库连接,以及如何以及在何处生成Prisma客户端。

datasource db {
  provider = "postgresql"
  url      = "postgres://user:passwd@localhost:5436/state_transitions_postgres"
}

generator client {
  provider = "prisma-client-js"
  output   = "../dist"
}

model StateTransition {
  id          Int      @id @default(autoincrement())
  user        String
  block       String
  version     Int
  transition  String
  timestamp   DateTime @default(now())
}

要创建Postgres数据库,我将在工作区root上创建一个docker-compose.yml文件。基本上,没有什么特别的,可以启动和运行所需的最小配置。

$ cat docker-compose.yml 
version: '3.7'
services:
  state_transitions_postgres:
    container_name: state_transitions_postgres
    image: postgres:14-alpine
    restart: always
    environment:
      POSTGRES_USER: ${STATE_TRANSITIONSPOSTGRES_USER:-user}
      POSTGRES_PASSWORD: ${STATE_TRANSITIONSPOSTGRES_PASSWD:-passwd}
      POSTGRES_DB: ${STATE_TRANSITIONSPOSTGRES_DB:-state_transitions_postgres}
    ports:
      - ${STATE_TRANSITIONSPOSTGRES_PORT:-5436}:5432
    volumes:
      - state_transitions_data:/var/lib/postgresql/data
volumes:
  state_transitions_data: ~
networks:
  example-net:
    driver: bridge

由于docker-compose的工作原理,容器配置生存在工作区根上时,我们仍然可以让package脚本封装逻辑,以使数据库向上,向下等待数据库,等等。我们可以从简单的updown package开始看起来像这样的脚本。

"up": "docker-compose up state_transitions_postgres",
"down": "docker stop -t 15 state_transitions_postgres"

如果我们提起容器,我们可以创建初始迁移。

pnpm exec prisma migrate dev --name init

随着我们的数据库启动和运行,我们现在可以将服务实现连接到使用。返回@state-transitions/service让我们对数据库软件包添加一个依赖性。

pnpm add @state-transitions/database

prisma docs中,看起来我们可以创建一个快速插件来实例化Prisma。我们将安装必要的深度。

pnpm add fastify-plugin

然后创建插件

import fp from "fastify-plugin";
import { FastifyPluginAsync } from "fastify";
import { PrismaClient } from "@state-transitions/database";

declare module "fastify" {
  interface FastifyInstance {
    prisma: PrismaClient;
  }
}
const prismaPlugin: FastifyPluginAsync = fp(async (server) => {
  const prisma = new PrismaClient();

  try {
    await prisma.$connect();
  } catch {
    server.log.warn("Not connected to database");
  }
  server.decorate("prisma", prisma);
  server.addHook("onClose", async (server, done) => {
    server.log.info("Shutting down prisma connection");
    await prisma.$disconnect();
    done();
  });
});
export default prismaPlugin;

此外,我们需要在服务器的初始化中注册插件。

diff --git a/services/@state-transitions/service/src/index.ts b/services/@state-transitions/service/src/index.ts
index 9d54a16..df32dde 100644
--- a/services/@state-transitions/service/src/index.ts
+++ b/services/@state-transitions/service/src/index.ts
@@ -1,10 +1,13 @@
 import { fastify } from "fastify";
 import { fastifyConnectPlugin } from "@bufbuild/connect-fastify";

+import prismaPlugin from "./plugin/prisma";
+
 import routes from "./connect";

 export const server = fastify();

+server.register(prismaPlugin);
 server.register(fastifyConnectPlugin, {
   routes,
 });

现在我们可以使用Prisma将RPC映射到数据库请求。

diff --git a/services/@state-transitions/service/src/connect.ts b/services/@state-transitions/service/src/connect.ts
index a39e1c5..aaa343f 100644
--- a/services/@state-transitions/service/src/connect.ts
+++ b/services/@state-transitions/service/src/connect.ts
@@ -1,4 +1,5 @@
 import { ConnectRouter } from "@bufbuild/connect";
+import { Timestamp } from "@bufbuild/protobuf";
 import {
   GetStateTransitionRequest,
   HealthCheckResponse_ServingStatus,
@@ -7,18 +8,31 @@ import {
   StateTransitionService,
 } from "@state-transitions/definition";

+import { server } from "./";
+
 export default (router: ConnectRouter) => {
   router.service(StateTransitionService, {
-    stateTransition(_: StateTransitionRequest) {
+    async stateTransition(request: StateTransitionRequest) {
+      await server.prisma.stateTransition.create({
+        data: {
+          ...request,
+          timestamp: request.timestamp?.toDate() || Date.now().toString(),
+        },
+      });
       return {
         status: ResponseStatus.ACCEPTED_UNSPECIFIED,
       };
     },
-    getStateTransition(request: GetStateTransitionRequest) {
+    async getStateTransition(request: GetStateTransitionRequest) {
+      const transition = await server.prisma.stateTransition.findFirstOrThrow({
+        where: {
+          ...request,
+        },
+      });
+
       return {
-        user: request.user,
-        block: request.block,
-        version: request.version,
+        ...transition,
+        timestamp: Timestamp.fromDate(transition.timestamp),
       };
     },
     healthCheck() {

再次,我们可以卷曲(如上)确认RPC正在工作。我们需要提起DB并在此之前运行服务器。

我将快速安装我们构建和开源的CLI,称为please。它使一个命令中的不同软件包上的运行脚本变得轻而易举。

pnpm add -Dw @dopt/please;
pnpm exec please start:@state-transitions/service up:@state-transitions/database

请参阅下面的控制台输出。
please

通过我们的服务和数据库启动和运行,我们可以使用卷发来创建状态过渡。

$ curl \
  --header 'Content-Type: application/json' \
  --data '{ "user": "9fke93ur23-1", "block": "394208feop12e", "version": 0, "transition": "next", "timestamp": "1099-10-21T07:52:58Z" }' \
  http://localhost:8080/proto.transitions.v1.StateTransitionService/StateTransition

如果我们打开数据库,我们可以确认记录是正确创建的。

$ docker exec -it 59a24bd34979 psql -U user -W state_transitions_postgres
Password: 
psql (14.6)
Type "help" for help.

state_transitions_postgres=# select * from "StateTransition";
 id |     user     |     block     | version | transition |      timestamp      
----+--------------+---------------+---------+------------+---------------------
 77 | 9fke93ur23-1 | 394208feop12e |       0 | next       | 1099-10-21 07:52:58
(1 row)

state_transitions_postgres=#

太好了,现在让我们更新测试。既然数据库需要启动并运行才能正常工作,那么我们的测试变得越来越复杂。我要编写一个简单的测试跑者脚本。它将负责

  • 提出DB
  • 等待数据库准备就绪
  • 运行测试
  • 放下DB
  • 根据测试结果返回正确的状态代码

第一次传球看起来像这样

#!/bin/bash

pnpm --filter @state-transitions/database run up & 

# a bit hacky - wait for postgres to come up
while ! curl http://localhost:5436/ 2>&1 | grep '52'
do
  sleep 1
done

pnpm run test:e2e;

TEST_EXIT_STATUS=$?

pnpm --filter @state-transitions/database down;

exit $TEST_EXIT_STATUS;

我们可以将test软件包脚本连接到跑步者,并将原始测试创新放入单独的脚本中。

"test": "./bin/runner.sh",
"test:e2e": "vitest run ./src/__tests__/"

您可以直接在此软件包上运行测试脚本,也可以通过工作区级测试脚本运行所有测试。

接下来怎么办

我希望本教程内容丰富且乐于助人。在随后的帖子中,我们将通过网关分享我们从内部服务传播数据的经验。