Nestjs微服务开发与部署实战

封面图来自Pixiv画师 炭涂 海豹发改委

大家好,我是GaoNeng,最近几天在折腾Nest微服务开发和部署,网络上这一部分的教程还挺少的(不排除我关键字没写对的可能),所以便自己折腾了一会,部署了一个最简单的用户服务+网关服务。希望这篇文档可以对你有帮助。

阅读完本文,你将会学习到Nest.js Grpc微服务开发、docker镜像 git action自动化构建与发布、K8S部署上线。

文章阅读大概需要5-10分钟

微服务

微服务架构是一种将应用程序拆分成多个小型服务的架构风格,每个服务都可以独立部署、扩展和维护

优势

  • 解决了代码复杂度的问题:将应用程序拆分为多个可管理的分支或服务,即使功能不变,也可以更好地组织和维护代码。
  • 独立部署:每个服务可以独立部署,无需考虑其他服务对其影响,加快了迭代速度和灵活性。
  • 独立扩容:每个服务可以根据需要独立扩容,可以更好地应对流量激增和负载均衡。
  • 技术栈自由:微服务架构不限定技术栈,只要不同服务之间使用相同的协议进行通信即可,例如可以使用不同的编程语言和技术栈开发不同的服务,然后通过统一的通信协议进行交互,提供了更大的灵活性和技术选型的自由度。

劣势

架构没有银弹,微服务自然也有它的劣势。以下是我在开发期间思考的问题

  • 部署繁琐: 由于微服务包含多个独立的服务,部署和管理这些服务的复杂性较高。
  • 运维繁琐: 高效治理与监控大量微服务是一个难题
  • 分布式问题: 微服务架构涉及到分布式系统,需要处理分布式事务和一致性等问题
  • 测试问题: 除了单元测试外,还需要进行集成测试以确保各个微服务之间的协作正常。
  • 性能问题: 微服务架构中的服务之间的通信可能面临信息传递慢或信息丢失等性能问题。

综上所述,架构没有银弹。如果你需要更细颗粒度的更新和扩容,那么微服务的确是一个不错的选择。如果你不需要的话,那么使用微服务架构可能会带来额外的复杂性和挑战。在决定采用微服务架构之前,务必权衡利弊并根据具体情况进行选择。

开发一个微服务

微服务并不限定技术栈,所以我在这里选择了我最熟悉的nest.js,通讯协议则是使用了grpc

初始化项目

与正常的项目开发流程一样,我们需要先初始化服务

nest new microservice --skip-git -p pnpm
cd microservice
nest g app user --no-spec
nest g app gateway --no-spec
rm -rf apps/microservice

现在我们就创建好了一个user服务和gateway服务,但是我们还需要修改一下nest-cli.json文件

{
- "sourceRoot": "apps/microservice/src",
+ "sourceRoot": "apps/gateway/src",
"compilerOptions": {
- "tsConfigPath": "apps/microservice/tsconfig.app.json"
+ "tsConfigPath": "apps/gateway/tsconfig.app.json"
},
"monorepo": true,
- "root": "apps/microservice"
+ "root": "apps/gateway",
"projects": {
- "microservice": {
- "type": "application",
- "root": "apps/microservice",
- "entryFile": "main",
- "sourceRoot": "apps/microservice/src",
- "compilerOptions": {
- "tsConfigPath": "apps/microservice/tsconfig.app.json"
- }
- }
}
}

编写protobuf

因为我们使用的通信协议是grpc,所以我们需要来编写一个protobuf。不了解protobuf语法的同学,可以去看一下这篇文章

// proto/UserService.proto
syntax = "proto3";
package User;
message Empty {}
service UserService {
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc CreateUser(CreateUserRequest) returns (User);
rpc UpdateUser(UpdateUserRequest) returns (User);
rpc DeleteUser(DeleteUserRequest) returns (Empty);
}
message User {
string email=1;
string nick=2;
string description=3;
string password=4;
}
message ListUsersRequest {
int32 offset=1;
int32 limit=2;
}
message GetUserResponse{
optional User payload=1;
}
message GetUserRequest {
string email=1;
}
message CreateUserRequest {
string email=1;
string nick=2;
string description=3;
string password=4;
}
message UpdateUserRequest {
string email=1;
User user = 2;
}
message DeleteUserRequest {
string email=1;
}
message ListUsersResponse {
repeated User users=1;
int32 total=2;
}

编写数据库模块

在真实项目开发中, 我认为数据库最好是放在一个libraries中. 这样其他服务只需要在module中引入即可.

nest g lib db --no-spec
pnpm install @nestjs/mongoose mongoose --save
rm -rf libs/db/src/db.service.ts libs/db/src/db.service.spec.ts

然后我们修改一下index.ts的导出文件

export * from './db.module';
- export * from './db.service';
// libs/db/src/db.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
MongooseModule.forRootAsync({
useFactory() {
return {
uri:
process.env.DB_URL ??
'mongodb://127.0.0.1:27017/?directConnection=true',
};
},
}),
],
})
export class DbModule {}

DB_URL这个环境变量,我们可以在K8S运行期间替换掉。后面的默认值则是我们开发时候使用的本地测试环境。

定义schema

schema在mongoose中是对于mongodb集合的映射,并定义集合中的结构。后续我们需要使用model来创建集合。

先让我们执行如下命令

nest g lib schema --no-spec # 创建名为schema的lib
rm -rf libs/schema/* # 删除schema下的所有文件
touch libs/schema/index.ts libs/schema/user.schema.ts # 创建index.ts和user.schema.ts
echo "export * from './user.schema.ts'" >> libs/schema/index.ts # 重新导出,日后我们只需要导入这个文件即可

现在,让我们开始定义一个user schema

// libs/schema/user.schema
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
@Schema({ autoCreate: true, _id: true })
export class User {
@Prop({ required: true, index: true })
email: string;
@Prop({ required:true })
nick: string;
@Prop({ required:true })
description: string;
@Prop({ required:true })
password: string;
}
export type UserDocument = HydratedDocument;
export const UserSchema = SchemaFactory.createForClass(User);

User服务开发

我们先来开发服务的启动文件,启动文件没什么好注意的,只需要注意将url改为0.0.0.0:就可以,否则其他服务连接不上。

async function bootstrap() {
const url = '0.0.0.0:3000' ?? process.env.USER_URL; // 这里需要使用的 0.0.0.0 而不是 localhost/127.0.0.1 否则其他服务连不上
const app = await NestFactory.createMicroservice(
UserModule,
{
transport: Transport.GRPC, // 是用的是GRPC协议
options: {
url,
package: 'User', //
protoPath: './proto/UserService.proto', // 这里写protobuf的地址
},
},
);
await app.listen();
}
bootstrap();

紧接着,我们来注入依赖。依赖比较少,只有数据库连接和Schema.

// apps/user/src/user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { DbModule } from '@app/db';
import { MongooseModule } from '@nestjs/mongoose';
import { User, UserSchema } from '@app/schema';
@Module({
imports: [
DbModule,
MongooseModule.forFeature([
{
name: User.name,
collection: User.name.toLowerCase(),
schema: UserSchema,
},
]),
],
controllers: [UserController],
providers: [UserService],
})
export class UserModule {}

数据校验

我们可以使用class-validator对微服务和网关服务进行参数校验。先让我们来安装一下class-validatorclass-transformer

pnpm install class-transformer class-validator --save

网关服务也需要对传参进行校验,所以在这里抽象成了一个libraries。

nest g lib dto --no-spec
rm -rf libs/src/*
cat "export * from './user.ts'" >> libs/dto/src/index.ts
touch libs/dto/src/user.ts

接下来就可以开始写DTO了

// libs/dto/src/user.ts
import { User } from '@app/schema';
import {
IsEmail,
IsNotEmpty,
IsObject,
IsPositive,
IsString,
} from 'class-validator';
export class ListUserReuqest {
@IsPositive()
offset: number;
@IsPositive()
limit: number;
}
export class GetUserRequest {
@IsString()
@IsNotEmpty()
email: string;
}
export class CreateUserRequest {
@IsNotEmpty()
@IsString()
@IsEmail()
email: string;
@IsNotEmpty()
@IsString()
nick: string;
@IsNotEmpty()
@IsString()
description: string;
@IsNotEmpty()
@IsString()
password: string;
}
export class UpdateUserRequest {
@IsNotEmpty()
@IsString()
@IsEmail()
email: string;
@IsNotEmpty()
@IsObject()
user: User;
}
export class DeleteUserRequest {
@IsNotEmpty()
@IsString()
@IsEmail()
email: string;
}

写完DTO之后,我们需要启用全局管道,修改main.ts

+ import { ValidationPipe } from '@nestjs/common';
+ app.useGlobalPipes(new ValidationPipe());
await app.listen();

服务开发

服务开发和平常的服务开发没什么区别,明确好这个服务的边界就行。不想写的可以直接copy仓库

网关服务开发

用户向网关发送信息,网关则是负责向微服务发送信息。将微服务返回的信息聚合在一起后,返回给用户。大概如下图所示

sequenceDiagram
actor User
actor Gateway
actor Service-A
actor Service-B
actor Service-C
User ->> Gateway: /path
Gateway ->> Service-A: request
Service-A ->> Gateway: response
Gateway ->> Service-B: request
Service-B ->> Gateway: response
Gateway ->> Service-C: request
Service-C ->> Gateway: response
Gateway ->> Gateway: aggregation data
Gateway ->> User: before aggregation data

grpc通信可以是同步,也可以是异步,在这里我只是画成了同步。

目前,网关服务的端口号是3000,我们需要稍微修改一下,否则本地跑不起来(因为会和用户服务有冲突)。顺便我们给网关配置一下数据校验管道。

+import { ValidationPipe } from '@nestjs/common';
-await app.listen(3000);
+await app.listen(4000);
+app.useGlobalPipes(new ValidationPipe());

依赖注入

我们需要使用ClientModule来注入依赖,这样才可以在controller中使用微服务。

// apps/gateway/src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { ClientsModule, Transport } from '@nestjs/microservices';
@Module({
imports: [
ClientsModule.register([
{
name: 'user',
transport: Transport.GRPC,
options: {
url: process.env.USER_URL ?? 'localhost:3000', // 默认值是为了本地调试
package: 'User',
protoPath: './proto/UserService.proto',
},
},
]),
],
controllers: [AppController],
})
export class AppModule {}

之后,我们需要在controllerinject注册好的依赖

//apps/gateway/src/app.controller.ts
import {
Controller
} from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
@Controller()
export class AppController {
private service: UserService;
constructor(
@Inject('user')
private client: ClientGrpc,
) {
this.service = this.client.getService(UserService.name);
}
}

拿到服务后,我们就可以直接写路由调用服务了。

//apps/gateway/src/app.controller.ts
// 上略
export class AppController {
@Get('/users')
async listUsers(
@Query('offset') offset: number,
@Query('limit') limit: number,
) {
return this.service.listUsers({ offset, limit });
}
@Get('/user/:email')
async getUser(@Param('email') email: string) {
if (!email) {
throw new HttpException(
'EMAIL_SHOULD_NOT_BE_EMPTY',
HttpStatus.BAD_REQUEST,
);
}
return this.service.getUser({ email });
}
@Post('/user')
async createUser(@Body() user: CreateUserRequest) {
return this.service.createUser(user);
}
@Put('/user')
async updateUser(@Body() user: UpdateUserRequest) {
return this.service.updateUser(user);
}
@Delete('/user')
async deleteUser(@Body() data: DeleteUserRequest) {
return this.service.deleteUserRequest(data);
}
}

镜像构建

在这里我们使用docker来构建镜像, 我们先来构建用户服务。在apps/user/下创建dockerfile

# alpine 打包出来的镜像更小
FROM node:18-alpine
WORKDIR /usr/user
COPY . .
RUN npm install -g pnpm &&
pnpm install &&
pnpm build user
ENV DB_URL=''
CMD ["node", "./dist/apps/user/main.js"]
EXPOSE 3000

这里我使用的是Node.js 18

紧接着我们在apps/gateway下创建dockerfile来构建网关服务. 和用户服务差不多,只是改了对外暴露的端口和WORKDIR而已

FROM node:18-alpine
WORKDIR /usr/gateway
COPY . .
RUN npm install -g pnpm &&
pnpm install &&
pnpm build gateway
ENV USER_URL=""
CMD ["node", "./dist/apps/gateway/main.js"]
EXPOSE 4000

自动化发布

手动构建和发布过于繁琐,不利于快速迭代,我们更希望推送一个版本后直接发布到docker上。我们不妨使用git actions来实现自动化发布。不过要实现自动化发布,需要我们有docker hub的账号。直接前往官网,点击sign up进行注册. 注册完成后,我们需要创建一个access_token

点击Account Setting -> security -> New Access Token后, 在弹出的对话框中输入token的名称和对应的权限后,点击Generate即可生成access_token

Snipaste_2023-10-01_14-17-29.png

紧接着,我们需要创建两个docker仓库,名字可以随便取,我在这里取名为

  • k8s-real-combat-gateway
    • 网关服务
  • k8s-real-combat-server
    • 用户服务

接下来我们开始编写workflows文件,为了加快构建速度,这里将网关服务和用户服务分成了两个job,并行构建。

name: Build
on:
pull_request:
workflow_dispatch:
push:
tags:
- "*.*.*"
jobs:
build_gateway:
name: Build docker gateway image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
/
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: apps/gateway/dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build_service:
name: Build docker service image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
/
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: apps/user/dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

紧接着,我们来到代码仓库中,点击setting -> secrets -> actions,创建两个repository secrets。分别是DOCKERHUB_USERNAMEDOCKERHUB_TOKEN. 其中 DOCKERHUB_USERNAME 填写你的docker用户名, DOCKERHUB_TOKEN 填写我们刚刚生成的 access_token

之后我们需要将代码push到仓库,并打上一个tag,然后将tag push到仓库中。之后我们就可以看到,git actions就正在自动构建了。

Snipaste_2023-10-01_14-30-39.png

K8S 部署

在构建期间,我们可以先编写者K8S的部署文件。

部署mongodb

由于我们的用户服务依赖数据库,所以我们先来部署mongodb

# mongodb.yaml
apiVersion: v1
kind: Service
metadata:
name: mongo
namespace: default
spec:
type: NodePort
ports:
- name: mongo
port: 27017
targetPort: 27017
nodePort: 30017
protocol: TCP
selector:
app: mongo
kubectl apply -f mongodb.yaml

创建成功后,我们可以执行kubectl get deploy如果显示如下,即为创建成功

NAME READY UP-TO-DATE AVAILABLE AGE
mongo-deploy 1/1 1 1 4m52s

当然,也可以使用MongoDBCompass连接一下30017端口来测试一下,能连接上就代表成功了。

image.png

接下来,我们开始编写微服务与网关的构建文件. 因为我们这里不需要负载均衡、金丝雀发布、灰度部署,所以用不到ingress,如果你需要上述功能,那么则需要ingress了。

apiVersion: v1
kind: ConfigMap
metadata:
name: config-map
data:
db-url: mongodb://mongo:27017/?directConnection=true
user_url: user-svc:3000
---
apiVersion: v1
kind: Service
metadata:
name: user-svc
spec:
type: NodePort
selector:
app: user
ports:
- protocol: TCP
port: 3000
targetPort: 3000
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-deploy
labels:
app: user
spec:
replicas: 1
selector:
matchLabels:
app: user
template:
metadata:
labels:
app: user
spec:
containers:
- name: user
image: gaonengwww/k8s-real-combat-server
ports:
- containerPort: 3000
env:
- name: DB_URL
valueFrom:
configMapKeyRef:
name: config-map
key: db-url
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gateway
labels:
app: gateway
spec:
replicas: 1
template:
metadata:
labels:
app: gateway
spec:
containers:
- name: gateway
image: gaonengwww/k8s-real-combat-gateway
ports:
- containerPort: 4000
env:
- name: USER_URL
valueFrom:
configMapKeyRef:
name: config-map
key: user_url
selector:
matchLabels:
app: gateway
---
apiVersion: v1
kind: Service
metadata:
name: gateway-svc
spec:
type: NodePort
selector:
app: gateway
ports:
- protocol: TCP
port: 4000
targetPort: 4000
nodePort: 30400

接下来执行

kubectl describe -f user.yaml

等待创建成功后,输入 curl localhost:30400/users 如果返回 {"total":0} 即为创建成功

仓库地址:github.com/GaoNeng-wWw…