node服务端框架nestjs使用指北

2,319 阅读15分钟

nestjs简介

对于构建web前端的框架,如Vue/React/Angular大家都很熟悉,它们利用JavaScript渐进式编程的能力来提高开发者的效率,并且能够创建快速、可测试和可扩展的前端应用程序。而nestjs就是之于node.js而产生的渐进式构建服务端应用程序的框架。

如同官网上所说,Nest 在这些常见的 Node.js 框架 (Express/Fastify) 之上提高了一个抽象级别,但仍然向开发者直接暴露了底层框架的 API,这使得开发者可以自由地使用适用于底层平台的无数的第三方模块。从而达到能够高效构建可扩展的 Node.js 服务器端应用程序的能力。

一个轮子的诞生一定是有自己的设计理念(设计模式),nest对比koa/express/原生的nodejs的区别是什么呢?Vue框架中使用了MVVM的设计模式,即数据驱动视图,视图反过来影响数据,所以不需要去操作DOM,只需要做好数据管理,DOM操作在底层已经封装好了,并且vue是渐进式的web框架;类比一下,nestjs的灵感来源于Angular,采用装饰器模式 (decorator)来实现完全模块化 构建,解耦解得非常彻底,并且nest是渐进式的node框架。

啰嗦警告⚠️

装饰器目前还在ECMAScript的stage 2阶段,可能大多数人还不是很熟悉,如果不懂装饰器的话建议看看我的这篇讲解文章装饰器到底给前端带来了什么,不然后面就很难理解nestjs的用法,因为在nestjs里装饰器贯穿始终,就像用Vue里要理解响应式原理一样。nestjs也全部采用TypeScript语法,并结合了 OOP (面向对象编程)、FP (函数式编程)和 FRP (函数响应式编程)等方式,这就和Angular一样,TS+装饰器导致新手上手的成本非常高。所以虽然GitHub的star有43k但是在国内还是处于蛮荒时期,不像Vue出生就自带新手光环😇(我是老阴阳人了。

这是官网地址nestjs.com/

你的第一个“ hello nestjs”

nest对node的版本要求是≥10.13.0。接下来一些讲解为了让大家更加理解会用Vue框架做类比,可能在概念上会存在一些小的偏差,有错误了请指正。

安装nest脚手架并新建项目

$ npm i -g @nestjs/cli
$ nest new my-first-nestjs

然后新建项目之后就可以启动了:

cd my-first-nestjs
npm run start

这里并不会像别的脚手架会给你选择项目配置,直接就生成了,生成的文件里默认是包括单元测试的。

目录结构讲解

主要就是讲src目录下的文件。

src
|—— app.controller.spec.ts
|—— app.controller.ts
|—— app.module.ts
|—— app.service.ts
|—— main.ts

main.ts

先看main.ts,这里的main.ts的作用和Vue里的main.ts不能说一模一样简直是如出一辙。剩余的app开头的文件可以理解为Vue里的app.vue文件的作用,app.vue的作用是注册一个vue实例并在渲染后挂载到id为app的DOM上,然后后续的开发都是基于此的。而这里的app开头的文件也是一样,默认开启了一个localhost:3000端口的服务端接口,然后后续的开发也是基于此,只不过这里将这个功能拆分出了几个文件。

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

//对比一下express是怎么启动一个服务的
const express = require('express');
const app = express();

app.listen(3000, () => {
  //callback
});

express中定义的app是express框架导出的一个顶层函数执行后返回的包含所有方法的对象。但是在nest里是根据用户自己定义的AppModule来生成app应用。

Controller

首先是讲controller。在nest里,controller的作用就是一个单路由,类型是一个装饰器。controller.spec.ts是它的单元测试。

下面是官网的图片:

image.png

假设你用过express/koa等框架,那么你一定对app.get/post/update/delete等API很熟悉。如果想要去请求/list页面的资源,在express会这样写:

app.get('/list', middleware, (req, res) => {
 //回调函数用来处理逻辑
})

@Controller() 装饰器中使用路径前缀可以使我们轻松地对一组相关的路由进行分组,并最大程度地减少重复代码。在nest里controller的底层就是对express的封装,看看封装后的controller.ts文件的代码怎么请求/list页面的资源:

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

// @Controller()装饰AppController类
@Controller()
export class AppController {
  // constructor(private readonly appService: AppService) {}
  
  // @Get()装饰getHello方法
  @Get('list')
  getHello(): string {
    //return this.appService.getHello();
    return 'hello nestjs!';
  }
}

为了方便理解,先把getHello函数里的constructorreturn语句注释掉,return语句改成直接return 'hello nestjs!',等到后面讲到了service再说。

@Controller()里的参数不填默认就是'/'@Get('list')在Get装饰器里传入地址,在每次client端对/list进行访问时都会执行getHello函数。所以这个时候访问localhost:3000/list页面上会有hello world!。

如果想进行其他请求,继续在页面下写;

想要middleware约束请求时引入限制的装饰器进行装饰即可;

想要req/res参数,注入即可;

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import Request from 'express'

// @Controller()装饰AppController类
@Controller()
export class AppController {
  // constructor(private readonly appService: AppService) {}
  
  // @Get()装饰getHello方法
  @Get('list')
  // @Req去装饰参数request
  // 为了用到express里request的代码提示还需要导入express中Request类去定义request
  getHello(@Req() request: Request): string {
    //return this.appService.getHello();
    return 'hello nestjs!';
  }
  
  @Post()
  postHello(): object {
    return { succuss: true };
  }
}

Request 对象代表 HTTP 请求,并具有查询字符串,请求参数参数,HTTP 标头(HTTP header) 和 正文(HTTP body)的属性(在这里阅读更多)。在多数情况下,不必手动获取req,因为可能我们只需要req的某个方法,没有必要把整个对象导入进来。 nest封装了几个专用的装饰器,开箱即用的 @Body()@Query()@Param() ,就不需要再导入express框架的类,减少对外部框架的依赖。 下面是 Nest 提供的装饰器及其代表的底层平台特定对象的对照列表。

image_1.png

路由相关的API在官网的controller这一章都能找到,这里就不一一展示了。

最后定义好的AppController 类需要在app.modules.ts里去注册,不然 Nest 不知道 AppController 是否存在,所以也不会创建这个类的实例。

遵循控制器在模块里注册的原则,我们需要在 @Module() 装饰器中写入 controllers 数组来进行注册。这里就像把vue里的component组件写入到app.vue文件里一样,这样组件就可以运行起来了。所以在app.module.ts的文件里会看到这样的代码:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  // 写入AppController,后续有别的控制器继续在数组后面添加即可
  controllers: [AppController],
  // providers: [AppService],
})
export class AppModule {}

本小节总结:在nestjs里Controller就是一个路由控制器,负责处理传入的请求 和向客户端返回响应

Provider

在前面创建Controller的时候,我们注释了app.controller.ts里的一段有关service的代码,现在我们将它加上来分析。

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

// @Controller()装饰AppController类
@Controller()
export class AppController {
  //在constructor中注入appService依赖
  constructor(private readonly appService: AppService) {}
  
  // @Get()装饰getHello方法
  @Get('list')
  getHello(): string {
    return this.appService.getHello();
    //return 'hello nestjs!';
  }
}

首先,需要先理解为什么这样用:假设我们直接在getHello函数里实现业务功能,那么这个函数里的代码可能会越堆越多,并且如果函数中一些业务代码可以在别的地方复用的话,那在别的地方用的时候还要到这个文件copy一份,这样就导致Controller控制器的功能不再单纯变得混乱而且重复代码也不友好,所以此时我们需要将在控制器中需要实现的复杂业务功能隔离出来,这就是service文件的作用。

理解了原因,接下来就是怎么做。其实做法很多,只要把业务逻辑写在别的地方然后引入到Controller里就行了,但是怎么引入是最优的呢?nestjs选择了用一种名为依赖注入(DI) 的设计模式来引入。关于依赖注入对比非依赖注入的优势请看我的文章装饰器到底给前端带来了什么

我们来看一下生成的app.service.ts的示例代码:

import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

可以看到service里的代码和Controller组件的代码区别就在于Injectable()装饰器,这个装饰器就是为了实现依赖注入(DI) 的设计模式的一种封装。在Service里实现有关于getHello的业务逻辑,Controller控制器需要依赖这个功能那么就通过constructor去注入这个依赖。

那么至此可以看出Provider(提供者)这个角色的作用和名字差不多,就是一个提供依赖给别的组件或者模块的角色,就是一个用 @Injectable() 装饰器注释的类,所以在nestjs里的很多类都可以被当做成提供者,只要你的作用是给别人提供依赖,比如service, repository, factory, helper 等等。 他们都可以通过 constructor 注入 依赖关系。

OK,我们再次返回代码中。此时我们已经定义了一个提供者app.service.ts,它将提供服务给app.controller.ts控制器使用,这时我们还需要在module里注册这个service才可以成功完成注入。那么,在app.module.ts里进行注册:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

到这里,目录结构没有解释的就还差module了。

本节小结:Provider是一个用 @Injectable() 装饰器注释的类,用来提供服务。

Module

从代码里看,Module是具有 @Module() 装饰器的类。 @Module() 装饰器提供了元数据,nest用它来组织应用程序结构。以下是官网的解释图:

image_2.png

每个 nest 应用程序至少有一个模块,即根模块,也就是我们现在在代理看到的app.module.ts文件。实际的项目肯定是由很多个模块组成的,每一个模块都会实现一组属于自己模块的功能,比如注册模块。

下面是@Module()装饰器接受描述模块属性的对象的含义:

image_3.png

在写代码的时候,我们经常都会听到模块化这个词语。对于写JavaScript的同学,说起模块化我们会想到ESModule,但是ESModule并不是设计上真正意义的模块化,前端也并没有对模块化有特别好的示范。对于nestjs里的Module,我觉得就是Typescript语言对模块化的一个最佳示范吧。

最后我们用一个图为nestjs默认产生的模块的作用做一个总结:

image_4.png

动手实现CRUD接口

这一章就简单演示一下怎么快速实现服务端CRUD,在这个过程我发现nestjs给的惊喜远远不止设计模式带来的代码彻底解耦这么简单。

在nest里新建不需要手动创建,只需要在命令行执行nest g xxx,会自动更新到根模块,非常方便。如果记不住的集美兄弟可以执行下面的命令得到nestjs命令行的用法和提示:

nest --help 

基本框架搭建

首先我们新建一个模块,假设这个模块的功能就是博客文章的增查改删吧,所以执行下面的命令新生成一个名为blog-articles的模块文件夹:

image_5.png

blog-articles里包含一个blog-articles.module.ts,然后我们还需要controller和service:

image_6.png

image_7.png

RECIPES(秘笈)

在官方文档左边有一个RECIPES部分,这部分就是绝绝子。

image_8.png

首先,我们来试试nest对Swagger的内置如何。Swagger简单来说就是一个描述 RESTful API 的工具,用于前后端联调接口。

npm install --save @nestjs/swagger swagger-ui-express

安装完成后在main.ts里引入。

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Swigger接口配置
  const options = new DocumentBuilder()
    .setTitle('blog-articles')
    .setDescription('XXX的blog的文章CRUD接口')
    .setVersion('1.0.0')
    .build();
  const document = SwaggerModule.createDocument(app, options);
  // 'api-doc'为Swagger UI 的挂载路径
  SwaggerModule.setup('api-doc', app, document);

  await app.listen(3000);
}
bootstrap();

然后npm run start之后,打开浏览器输入http://localhost:3000/api就可以看到属于blog-articles的Swagger界面。

image_9.png

此时表示有一个在根地址上的GET请求,这是app.controller.ts里的默认生成代码产生的,GET请求返回hello world!。点击右上角的Try it out还可以测试接口。

image_10.png

总之,就是非常完美啦。

然后我们就可以去blog-articles.controller.ts里去写CRUD接口,并且在Swagger页面调试接口。

数据库

不过在写之前,我们还需要一个数据库去响应我们的增删改查功能。此时,我的目光又看向了RECIPES里的Mongoose,它是MongoDB数据库的封装。在nestjs里开箱即用,我先来试试使用体验。

在nest里使用mongoose有两种方式:一是使用nestjs封装的@nestjs/mongoose;二是直接使用mongoose包和type自己定义。这里我选择前者。

npm install --save @nestjs/mongoose mongoose

这里其实可以把mongodb数据库也看成是是一个模块,这个模块只是实现了数据库的功能。安装成功后,在app.module.ts里去注册模块:

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { BlogArticlesModule } from './blog-articles/blog-articles.module';

@Module({
  //通过MongooseModule的forRoot方法将数据库进行连接,这里的forRoot就是mongoose的connect方法的封装
  imports: [MongooseModule.forRoot('mongodb://127.0.0.1/nest-api'), BlogArticlesModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

然后运行呢会发现控制台报错:ERROR [MongooseModule] Unable to connect to the database。原因是要使用数据库还需要在本地安装MongoDB数据库。地址在MongoDB下载地址,在页面右边直接下载安装之后就不会报错了。

最后,需要定义一个mongdb的schema,这个schema规定了输入数据的接口类型,具体知识可以看mongoose库的文档。在nest里也实现了@Schema的装饰器,@Schema 装饰器标记一个类作为Schema 定义,它将我们的 Article 类映射到 MongoDB 同名复数的集合 Articles,这个装饰器接受一个可选的 Schema 对象。可以类比为mongoose里schema用法的封装:new mongoose.Schema(_, options))

新建一个blog-articles.schema.ts的文件,定义了文章标题和文章内容:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type ArticleDocument = Article & Document;

// 使用@schema装饰器就不需要原来mongoose里schema实现方式了
@Schema()
export class Article extends Document {
  // @Prop是属性装饰器,可以传入一些可选属性来封装下面定义的属性
  // required: true表示title标题是必传的
  @Prop({ required: true })
  title: string;

  @Prop()
  content: string;
}

export const ArticleSchema = SchemaFactory.createForClass(Article);

定义好博客文章存储的schema之后,就可以在需要的模块里引入使用了。所以,这里我们需要在blog-articles.module.ts里去注册:

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Article, ArticleSchema } from './blog-articles.schema';
import { BlogArticlesController } from './blog-articles.controller';
import { BlogArticlesService } from './blog-articles.service';

@Module({
  //forFeature()方法来配置模块,包括定义哪些模型应该注册在当前范围中,在blog-articles里暂时先注册ArticleSchema 
  imports: [MongooseModule.forFeature([{ name: Article.name, schema: ArticleSchema }])],
  controllers: [BlogArticlesController],
  providers: [BlogArticlesService],
})
export class BlogArticlesModule {}

注册之后,我们就可以在blog-articles模块下的任何组件中使用它了,还是采用依赖注入的方式。

实现CRUD接口

还是本着模块化以及解耦的原则,CRUD的功能(服务)在blog-articles.service.ts里实现,然后在controller里采用依赖注入使用。下面是实现代码:

import { Body, Injectable, Param } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { ArticleDocument } from './blog-articles.schema';
import { Model } from 'mongoose';
import { ApiProperty } from '@nestjs/swagger';

// 用来定义create创建文章时所需要的数据类型
export class CreateArticleDto {
  //Api开头的装饰器都与Swagger有关,这里是给title加上中文说明
  @ApiProperty({ description: '文章标题' })
  title: string
  @ApiProperty({ description: '文章内容' })
  content: string
}

// 在这里加上@Injectable装饰器之后,被别的组件引入就不需要在contructor里写@Inject装饰器了
@Injectable()
export class BlogArticlesService {
  //在使用数据库之前先在constructor里注入数据库模型Articl,产生articleModel实例
  //'Article'是schema里的Aricle类的名字,在ES6之后class的name是一个默认产生的静态属性
  //基于TS的强大,这里的articleModel在TS里的智能提示和mongoose里一模一样,可以无缝使用
  constructor(@InjectModel('Article') private articleModel: Model<ArticleDocument>) { }

  // 增
  // @Body属性注入的方式也是一个装饰器封装,类似于express中的req.body.createArticleDto
  async createOne(@Body() createArticleDto: CreateArticleDto) {
    await this.articleModel.create(createArticleDto);
    return {
      succuss: true
    }
  }
  // 查
  // @Param表示req.params,这里传入id就是req.params.id
  async findOne(@Param('id') id: string) {
    return this.articleModel.findById(id);
  }

  async findAll() {
    return this.articleModel.find();
  }
  // 改
  async updateOne(@Param('id') id: string, @Body() articleDto: CreateArticleDto) {
    await this.articleModel.findByIdAndUpdate(id, articleDto)
    return {
      succuss: true
    }
  }
  // 删
  async deleteOne(@Param('id') id: string) {
    this.articleModel.findByIdAndDelete(id);
    return {
      success: true
    }
  }
}

在service里实现了CRUD之后,就可以提供服务给controller使用。这就是Provider和Controller的关系。下面是blog-articles.controller.ts的代码:

import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BlogArticlesService, CreateArticleDto } from './blog-articles.service';

@Controller('blog-articles')
@ApiTags('Articles-api')
export class BlogArticlesController {
  constructor(private articlesService: BlogArticlesService) { }
  @Post()
  @ApiOperation({ summary: '增加Create' })
  createOne(@Body() createArticleDto: CreateArticleDto) {
    return this.articlesService.createOne(createArticleDto);
  }

  @Get(':id')
  @ApiOperation({ summary: '查询Retrieve一条' })
  findOne(@Param('id') id: string) {
    return this.articlesService.findOne(id);
  }
  @Get()
  @ApiOperation({ summary: '查询Retrieve全部' })
  findAll() {
    return this.articlesService.findAll();
  }

  @Put()
  @ApiOperation({ summary: '修改Update' })
  update(@Param('id') id: string, @Body() articleDto: CreateArticleDto) {
    return this.articlesService.updateOne(id, articleDto);
  }

  @Delete()
  @ApiOperation({ summary: '删除Delete' })
  deleteOne(@Param('id') id: string) {
    return this.articlesService.deleteOne(id)
  }
}


最后CRUD大功告成,现在可以npm run dev然后打开swagger页面调试一下接口。

image_11.png

测试POST请求

image_12.png

测试GET请求

先测试getAll方法

image_13.png

返回了刚才POST上去的数据,在MongoDB里会默认分配一个独一无二的字符串id,这个id就是数据的唯一标识。

通过id测试getOne:

image_14.png

基本上都是🆗的,后面就不一一展示了,大家自行尝试。