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-validator
与class-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 {}
之后,我们需要在controller
中inject
注册好的依赖
//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
紧接着,我们需要创建两个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_USERNAME
和DOCKERHUB_TOKEN
. 其中 DOCKERHUB_USERNAME
填写你的docker用户名, DOCKERHUB_TOKEN
填写我们刚刚生成的 access_token
之后我们需要将代码push到仓库,并打上一个tag,然后将tag push到仓库中。之后我们就可以看到,git actions就正在自动构建了。
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
端口来测试一下,能连接上就代表成功了。
接下来,我们开始编写微服务与网关的构建文件. 因为我们这里不需要负载均衡、金丝雀发布、灰度部署,所以用不到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…