写在开头

每一篇文章都是作者用 写出,还需要花费大量时间去校对调整,旨在给您带来最好的阅读体验。


正文

Nest 是一个用于构建高效,可扩展的 Node.js 服务器端应用程序的框架。它使用渐进式 JavaScript,内置并完全支持 TypeScript 并结合了 OOP(面向对象编程),FP(函数式编程)和 FRP(函数式响应编程)的元素。

在底层,Nest 使用强大的 HTTP Server 框架,如 Express(默认)和 FastifyNest 在这些框架之上提供了一定程度的抽象,同时也将其 API 直接暴露给开发人员。这样可以轻松使用每个平台的无数第三方模块。

nest 怎么返回状态 nest设置步骤_数据库连接

从上图也可以看出,Nest 目前是热度仅次于老牌 Express,目前排名第二的 Nodejs 框架。

今天,我们通过本篇 Nest 快速通关攻略,使用 Nest 来打造一个旅游攻略,将使用到包括但不限于 Nest 的下列功能

  • 中间件
  • 管道
  • 类验证器
  • 守卫
  • 拦截器
  • 自定义装饰器
  • 数据库
  • 文件上传
  • MVC
  • 权限

本项目有一个目标,针对 Nest 文档中的简单案例,放到实际场景中,从而找到最佳实践。

好了,话不多说,我们准备开始吧!

初始化项目


首先,使用 npm i -g @nestjs/cli 命令安装 nest-cli,然后使用脚手架命令创建一个新的 nest 项目即可。(如下)

nest new webapi-travel

项目初始化完成后,我们进入项目,运行 npm run start:dev 命令启动项目吧!(项目启动后打开 (http://localhost:3000/)[http://localhost:3000/] 可查看效果)

nest 怎么返回状态 nest设置步骤_node.js_02

如上图所示,进入页面看到 Hello World 后,说明项目启动成功啦!

配置数据库

数据表设计

接下来,我们需要设计一下我们的数据表结构,我们现在准备先做一个 吃喝玩乐 店铺集锦,店铺需要展示这些信息:

  • 店铺名称
  • 店铺简介(slogan)
  • 店铺类型(吃喝玩乐)
  • 封面(单张图片)
  • 轮播图(多张图片)
  • 标签(多个)
  • 人均消费
  • 评分(0 - 5)
  • 详细地址
  • 经度
  • 纬度

从上面可以看出,我们至少应该要有两张表:店铺表、店铺轮播图表。

那么接下来,我们把两张表的 DDL 定义一下。(如下)

CREATE TABLE IF NOT EXISTS `shop` (
  `id` int PRIMARY KEY AUTO_INCREMENT,
  `name` varchar(16) NOT NULL DEFAULT '',
  `description` varchar(64) NOT NULL DEFAULT '',
  `type` tinyint unsigned NOT NULL DEFAULT 0,
  `poster` varchar(200) NOT NULL DEFAULT '',
  `average_cost` smallint NOT NULL DEFAULT 0 COMMENT '人均消费',
  `score` float NOT NULL DEFAULT 0,
  `tags` varchar(200) NOT NULL DEFAULT '',
  `evaluation` varchar(500) NOT NULL DEFAULT '',
  `address` varchar(200) NOT NULL DEFAULT '',
  `longitude` float NOT NULL DEFAULT 0,
  `latitude` float NOT NULL DEFAULT 0,
  index `type`(`type`)
) engine=InnoDB charset=utf8;

CREATE TABLE IF NOT EXISTS `shop_banner` (
  `id` int PRIMARY KEY AUTO_INCREMENT,
  `shop_id` int NOT NULL DEFAULT 0,
  `url` varchar(255) NOT NULL DEFAULT '',
  `sort` smallint NOT NULL DEFAULT 0 COMMENT '排序',
  index `shop_id`(`shop_id`, `sort`, `url`)
) engine=InnoDB charset=utf8;

其中 shop_banner 使用了联合索引,能够有效减少回表次数,并且能够将图片进行排序。创建完成后,可以检查一下。(如下图)

nest 怎么返回状态 nest设置步骤_node.js_03

配置数据库连接

在数据表初始化完成后,我们需要在 nest 中配置我们的数据库连接。在本教程中,我们使用 typeorm 库来进行数据库操作,我们先在项目中安装一下相关依赖。

npm install --save @nestjs/typeorm typeorm mysql2

然后,我们来配置一下数据库连接配置,在项目根目录下创建 ormconfig.json 配置文件。

{
  "type": "mysql",
  "host": "localhost", // 数据库主机地址
  "port": 3306, // 数据库连接端口
  "username": "root", // 数据库用户名
  "password": "root", // 数据库密码
  "database": "test", // 数据库名
  "entities": ["dist/**/*.entity{.ts,.js}"],
  "synchronize": false // 同步设置,这个建议设置为 false,数据库结构统一用 sql 来调整
}

数据库配置根据每个人的服务器设置而不一样,这个文件我并没有传到仓库中,大家想体验 Demo 的话,需要自己创建该文件。

配置完成后,我们在 app.module.ts 文件中,完成数据库连接配置。

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

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

typeorm 会自动载入项目中的 ormconfig.json 配置文件,所以不需要显示引入。

项目初始化

本篇文章将作为实战指南,会将部分内容提前,因为我认为这些内容才是 nest 中比较核心的部分。

验证器

nest 中,使用管道进行函数验证,我们先定义一个 ValidationPipe 用于校验,该文件内容如下:

// src/pipes/validate.pipe.ts

import {
  ArgumentMetadata,
  BadRequestException,
  Injectable,
  PipeTransform,
} from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || this.toValidate(metatype)) {
      return value;
    }

    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      const error = errors[0];
      const firstKey = Object.keys(error.constraints)[0];
      throw new BadRequestException(error.constraints[firstKey]);
    }

    return value;
  }

  // eslint-disable-next-line @typescript-eslint/ban-types
  private toValidate(metatype: Function): boolean {
    // eslint-disable-next-line @typescript-eslint/ban-types
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return types.includes(metatype);
  }
}

然后,我们在 main.ts 中,注册该管道为全局管道即可,代码实现如下:

// src/main.ts

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(7788);
}
bootstrap();

这样,我们就可以在实体中定义校验类,快速完成对单个字段的校验。

响应结果格式化

我想要所有的响应结果可以按照统一的格式返回,就是 code(状态码) + message(响应信息) + data(数据)的格式返回,这样的话,我们可以定义一个拦截器 ResponseFormatInterceptor,用于对所有的响应结果进行序列格式化。

代码实现如下:

// src/interceptors/responseFormat.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class ResponseFormatInterceptor implements NestInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> | Promise<Observable<any>> {
    return next.handle().pipe(
      // 将原有的 `data` 转化为统一的格式后返回
      map((data) => ({
        code: 1,
        message: 'ok',
        data,
      })),
    );
  }
}

然后,同样在 main.ts 中的 bootstrap 中注册该拦截器(如下)

app.useGlobalInterceptors(new ResponseFormatInterceptor());

错误统一处理

在这里,我希望我们的错误不返回错误的状态码(因为这可能会导致前端引发跨域错误)。所以,我希望将所有的错误都返回状态码 200,然后在响应体中的 code 中,再返回实际的错误码,我们需要写一个拦截器来实现该功能 —— ResponseErrorInterceptor

// src/interceptors/responseError.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ResponseErrorInterceptor implements NestInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> | Promise<Observable<any>> {
    return next.handle().pipe(
      catchError(async (err) => ({
        code: err.status || -1,
        message: err.message,
        data: null,
      })),
    );
  }
}

然后,同样在 main.ts 中的 bootstrap 中注册该拦截器(如下)

app.useGlobalInterceptors(new ResponseErrorInterceptor());

解析头部 token

在我们后续的鉴权操作中,我们准备使用头部传入的 token 参数。我希望在每个接口实际请求发生时,对 token 进行解析,解析出该 token 对应的用户信息,然后将该用户信息继续向下传递。

这里,我们需要实现一个中间件,该中间件可以解析 token 信息,代码实现如下:

// src/middlewares/auth.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction } from 'express';
import { UserService } from '../user/user/user.service';

@Injectable()
export class AuthMiddleware implements NestMiddleware {
  constructor(private readonly userService: UserService) {}

  async use(req: Request, res: Response, next: NextFunction) {
    const token = req.headers['token'];
    req['context'] = req['context'] || {};
    if (!token) return next();

    try {
      // 使用 token 查询相关的用户信息,如果该函数抛出错误,说明 token 无效,则用户信息不会被写入 req.context 中
      const user = await this.userService.queryUserByToken(token);
      req['context']['token'] = token;
      req['context']['user_id'] = user.id;
      req['context']['user_role'] = user.role;
    } finally {
      next();
    }
  }
}

然后,我们需要在 src/app.module.ts 中全局注册该中间件(如下)

export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    // * 代表该中间件在所有路由均生效
    consumer.apply(AuthMiddleware).forRoutes('*');
  }
}

路由守卫

我们在部分路由中需要设置守卫,只有指定权限的用户才能访问,这里需要实现一个路由守卫 AuthGuard 用于守卫路由,和一个自定义装饰器 Roles 用于设置路由权限。

代码实现如下:

// src/guards/auth.guard.ts
import {
  CanActivate,
  ExecutionContext,
  ForbiddenException,
  Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const req = context.switchToHttp().getRequest();
    const token = req.headers['token'];
    const user_id = req['context']['user_id'];
    const user_role = req['context']['user_role'];
    // 没有 token,或者 token 不包含用户信息时,认为 token 失效
    if (!token || !user_id) {
      throw new ForbiddenException('token 已失效');
    }

    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    // 没有角色权限限制时,直接放行
    if (!roles) {
      return true;
    }

    // 角色权限为 `admin` 时,需要用户 role 为 99 才能访问
    if (roles[0] === 'admin' && user_role !== 99) {
      throw new ForbiddenException('角色权限不足');
    }

    return true;
  }
}

下面是自定义装饰器的实现:

// src/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

由于守卫只针对部分路由生效,所以我们只需要在指定的路由使用即可。

店铺操作

接下来,我们回到项目中,准备开始完成我们的店铺操作,我们需要实现下面几个功能:

  • 查询所有店铺信息
  • 查询单个店铺信息
  • 增加店铺
  • 删除店铺
  • 修改店铺信息

注册 ShopModule

我们按照顺序创建 shop/shop.controller.tsshop/shop.service.tsshop/shop.module.ts。(如下)

// shop/shop.controller.ts
import { Controller, Get } from '@nestjs/common';

@Controller('shop')
export class ShopController {
  @Get('list')
  async findAll() {
    return 'Test Shops List';
  }
}
// shop/shop.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class ShopService {}
import { Module } from '@nestjs/common';
import { ShopController } from './shop.controller';
import { ShopService } from './shop.service';

@Module({
  controllers: [ShopController],
  providers: [ShopService],
})
export class ShopModule {}

在初始化完成后,别忘了在 app.module.ts 中注册 ShopModule

// app.module.ts
// ...
@Module({
  imports: [TypeOrmModule.forRoot(), ShopModule], // 注册 ShopModule
  controllers: [AppController],
  providers: [AppService],
})

注册完成后,我们可以使用 postman 验证一下我们的服务。(如下图)

nest 怎么返回状态 nest设置步骤_数据库_04

从上图可以看出,我们的路由注册成功了,接下来我们来定义一下我们的数据实体。

定义数据实体

数据实体在 typeorm 使用 @Entity 装饰器装饰的模型,可以用来创建数据库表(开启 synchronize 时),还可以用于 typeorm 数据表 CURD 操作。

我们在前面新建了两个数据表,现在我们来创建对应的数据实体吧。

// src/shop/models/shop.entity.ts
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { ShopBanner } from './shop_banner.entity';

export enum ShopType {
  EAT = 1,
  DRINK,
  PLAY,
  HAPPY,
}

// 数据表 —— shops
@Entity()
export class Shop {
  // 自增主键
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ default: '' })
  name: string;

  @Column({ default: '' })
  description: string;

  @Column({ default: 0 })
  type: ShopType;

  @Column({ default: '' })
  poster: string;

  // 一对多关系,单个店铺对应多张店铺图片
  @OneToMany(() => ShopBanner, (banner) => banner.shop)
  banners: ShopBanner[];

  @Column({ default: '' })
  tags: string;

  @Column({ default: 0 })
  score: number;

  @Column({ default: '' })
  evaluation: string;

  @Column({ default: '' })
  address: string;

  @Column({ default: 0 })
  longitude: number;

  @Column({ default: 0 })
  latitude: number;

  @Column({ default: 0 })
  average_cost: number;

  @Column({ default: '' })
  geo_code: string;
}
// src/shop/models/shop_banner.entity.ts
import {
  Column,
  Entity,
  JoinColumn,
  ManyToOne,
  PrimaryGeneratedColumn,
} from 'typeorm';
import { Shop } from './shop.entity';

@Entity()
export class ShopBanner {
  @PrimaryGeneratedColumn()
  id: number;

  // 多对一关系,多张店铺图片对应一家店铺
  // 在使用 left join 时,使用 shop_id 字段查询驱动表
  @ManyToOne(() => Shop, (shop) => shop.banners)
  @JoinColumn({ name: 'shop_id' })
  shop: Shop;

  @Column()
  url: string;

  @Column()
  sort: number;
}

从上面可以看出,我们的三个数据实体都有对应的装饰器描述,装饰器的用处大家可以参考 TypeORM 文档

新增店铺接口

在实体定义好以后,我们来写 新增店铺 接口。

该接口需要接收一个 店铺 对象入参,后续我们也用该对象来进行参数校验,我们先定义一下这个类。(如下)

// src/shop/dto/create-shop.dto.ts
import { IsNotEmpty } from 'class-validator';
import { ShopType } from '../models/shop.entity';

export class CreateShopDto {
  // 使用了 ValidationPipe 进行校验
  @IsNotEmpty({ message: '店铺名称不能为空' })
  name: string;

  description: string;

  @IsNotEmpty({ message: '店铺类型不能为空' })
  type: ShopType;

  poster: string;

  banners: string[];

  tags: string[];

  @IsNotEmpty({ message: '店铺评分不能为空' })
  score: number;

  evaluation: string;

  @IsNotEmpty({ message: '店铺地址不能为空' })
  address: string;

  @IsNotEmpty({ message: '店铺经度不能为空' })
  longitude: number;

  @IsNotEmpty({ message: '店铺纬度不能为空' })
  latitude: number;

  average_cost: number;
}

然后,我们在 ShopController 中添加一个方法,注册 新增店铺 接口。(如下)

// src/shop/shop.controller.ts
@Controller('shop')
export class ShopController {
  constructor(private readonly shopService: ShopService) {}

  // add 接口
  @Post('add')
  // 返回状态码 200
  @HttpCode(200)
  // 使用鉴权路由守卫
  @UseGuards(AuthGuard)
  // 定义只有 admin 身份可访问
  @Roles('admin')
  // 接收入参,类型为 CreateShopDto
  async addShop(@Body() createShopDto: CreateShopDto) {
    // 调用 service 的 addShop 方法,新增店铺
    await this.shopService.addShop(createShopDto);
    // 成功后返回 null
    return null;
  }
}

我们在接口请求发起后,调用了 service 的新增店铺方法,然后返回了成功提示。

接下来,我们来编辑 ShopService,来定义一个 新增店铺 的方法 —— addShop(如下)

export class ShopService {
  constructor(private readonly connection: Connection) {}

  async addShop(createShopDto: CreateShopDto) {
    const shop = this.getShop(new Shop(), createShopDto);

    // 处理 banner
    if (createShopDto.banners?.length) {
      shop.banners = this.getBanners(createShopDto);
      await this.connection.manager.save(shop.banners);
    }

    // 存储店铺信息
    return this.connection.manager.save(shop);
  }

  getShop(shop: Shop, createShopDto: CreateShopDto) {
    shop.name = createShopDto.name;
    shop.description = createShopDto.description;
    shop.poster = createShopDto.poster;
    shop.score = createShopDto.score;
    shop.type = createShopDto.type;
    shop.tags = createShopDto.tags.join(',');
    shop.evaluation = createShopDto.evaluation;
    shop.address = createShopDto.address;
    shop.longitude = createShopDto.longitude;
    shop.latitude = createShopDto.latitude;
    shop.average_cost = createShopDto.average_cost;
    shop.geo_code = geohash.encode(
      createShopDto.longitude,
      createShopDto.latitude,
    );
    return shop;
  }

  getBanners(createShopDto: CreateShopDto) {
    return createShopDto.banners.map((item, index) => {
      const banner = new ShopBanner();
      banner.url = item;
      banner.sort = index;
      return banner;
    });
  }
}

可以看到,ShopService 是负责与数据库交互的,这里先做了店铺信息的存储,然后再存储 店铺 banner店铺标签

在接口完成后,我们用 postman 来验证一下我们新增的接口吧,下面是我们准备的测试数据。

{
  "name": "蚝满园",
  "description": "固戍的宝藏店铺!生蚝馆!还有超大蟹钳!",
  "type": 1,
  "poster": "https://img1.baidu.com/it/u=2401989050,2062596849&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500",
  "banners": [
    "https://img1.baidu.com/it/u=2401989050,2062596849&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500",
    "https://img1.baidu.com/it/u=2043954707,1889077177&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400",
    "https://img1.baidu.com/it/u=1340805476,3006236737&fm=253&fmt=auto&app=120&f=JPEG?w=360&h=360"
  ],
  "tags": [
    "绝绝子好吃",
    "宝藏店铺",
    "价格实惠"
  ],
  "score": 4.5,
  "evaluation": "吃过两次了,他们家的高压锅生蚝、蒜蓉生蚝、粥、蟹钳、虾都是首推,风味十足,特别好吃!",
  "address": "宝安区上围园新村十二巷 5-6 号 101",
  "longitude": 113.151415,
  "latitude": 22.622297,
  "average_cost": 80
}

nest 怎么返回状态 nest设置步骤_node.js_05

查询店铺列表/更新店铺信息/删除店铺

在新增店铺完成后,我们来把查询店铺列表、更新店铺信息接口和删除店铺接口都完善一下。

首先,还是定义好对应的 controller 入口。

// src/shop/shop.controller.ts
@Controller('shop')
export class ShopController {
  constructor(private readonly shopService: ShopService) {}

  // 获取店铺列表接口
  @Get('list')
  async getShopList(@Query() queryShopListDto: QueryShopListDto) {
    const list = await this.shopService.getShopList(queryShopListDto);
    return {
      pageIndex: queryShopListDto.pageIndex,
      pageSize: queryShopListDto.pageSize,
      list,
    };
  }

  // update 接口
  @Post('update')
  @HttpCode(200)
  @UseGuards(AuthGuard)
  @Roles('admin')
  // 接收入参,类型为 UpdateShopDto
  async updateShop(@Body() updateShopDto: UpdateShopDto) {
    // 调用 service 的 addShop 方法,新增店铺
    await this.shopService.updateShop(updateShopDto);
    // 返回成功提示
    return null;
  }

  // delete 接口
  @Post('delete')
  @HttpCode(200)
  @UseGuards(AuthGuard)
  @Roles('admin')
  async deleteShop(@Body() deleteShopDto: QueryShopDto) {
    await this.shopService.deleteShop(deleteShopDto);
    return null;
  }
}

然后,我们将对应的 service 补全就好。

在更新店铺信息时,还需要处理一种情况,那就是店铺更新成功,但是店铺图片更新失败的情况。在这种情况下,该更新只有部分生效。

所以,店铺信息、店铺图片信息应该是要么一起存储成功,要么一起存储失败(通过事务回退),根据这个特性,我们这里将需要启用事务。

使用 TypeORM 中的 getManager().transaction() 方法可显式启动事务,代码实现如下:

export class ShopService {
  constructor(private readonly connection: Connection) {}

  async getShopList(queryShopListDto: QueryShopListDto) {
    const shopRepository = this.connection.getRepository(Shop);
    const { pageIndex = 1, pageSize = 10 } = queryShopListDto;
    const data = await shopRepository
      .createQueryBuilder('shop')
      .leftJoinAndSelect('shop.banners', 'shop_banner')
      .take(pageSize)
      .skip((pageIndex - 1) * pageSize)
      .getMany();

    return data
      .map((item) => {
        // 计算用户传入的位置信息与当前店铺的距离信息
        const distance = computeInstance(
          +queryShopListDto.longitude,
          +queryShopListDto.latitude,
          item.longitude,
          item.latitude,
        );
        return {
          ...item,
          tags: item.tags.split(','),
          distanceKm: distance,
          distance: convertKMToKmStr(distance),
        };
      })
      .sort((a, b) => a.distanceKm - b.distanceKm);
  }

  async updateShop(updateShopDto: UpdateShopDto) {
    return getManager().transaction(async (transactionalEntityManager) => {
      await transactionalEntityManager
        .createQueryBuilder()
        .delete()
        .from(ShopBanner)
        .where('shop_id = :shop_id', { shop_id: updateShopDto.id })
        .execute();

      const originalShop: Shop = await transactionalEntityManager.findOne(
        Shop,
        updateShopDto.id,
      );
      const shop = this.getShop(originalShop, updateShopDto);

      if (updateShopDto.banners?.length) {
        shop.banners = this.getBanners(updateShopDto);
        await transactionalEntityManager.save(shop.banners);
      }

      await transactionalEntityManager.save(shop);
    });
  }

  async deleteShop(deleteShopDto: QueryShopDto) {
    return getManager().transaction(async (transactionalEntityManager) => {
      await transactionalEntityManager
        .createQueryBuilder()
        .delete()
        .from(Shop)
        .where('id = :id', { id: deleteShopDto.id })
        .execute();

      await transactionalEntityManager
        .createQueryBuilder()
        .delete()
        .from(ShopBanner)
        .where('shop_id = :shop_id', { shop_id: deleteShopDto.id })
        .execute();
    });
  }
}

在查询列表接口时,还涉及到一个距离计算的点,由于该部分并不是 nest 的核心内容,所以这里就不做展开介绍了,感兴趣的童鞋可以找到 源码地址 进行阅读。

我们来看看效果吧。(如下图)

nest 怎么返回状态 nest设置步骤_数据库连接_06

至此,列表查询、更新店铺信息、删除店铺,都已经完成了。

文件上传

最后,再对一些衍生知识点进行介绍,比如使用 nest 如何进行文件上传。

这里可以使用 nest 自带提供的 FileInterceptor 拦截器和 UploadedFile 文件接收器,对文件流进行接收,然后再使用自己的图床工具,例如 oss 传输到自己的服务器上,下面是一段代码示例,可供参考。

// common.controller.ts
import {
  Controller,
  HttpCode,
  Post,
  UploadedFile,
  UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import '../../utils/oss';
import { CommonService } from './common.service';

@Controller('common')
export class CommonController {
  constructor(private readonly commonService: CommonService) {}

  @Post('upload')
  @UseInterceptors(FileInterceptor('file'))
  @HttpCode(200)
  uploadFile(@UploadedFile() file: Express.Multer.File) {
    return upload(file);
  }
}
// oss.ts
const { createHmac } = require('crypto');
const OSS = require('ali-oss');
const Duplex = require('stream').Duplex;
const path = require('path');

export const hash = (str: string): string => {
  return createHmac('sha256', 'jacklove' + new Date().getTime())
    .update(str)
    .digest('hex');
};

const ossConfig = {
  region: process.env.OSS_REGION,
  accessKeyId: process.env.OSS_ACCESS_KEY_ID,
  accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
  bucket: process.env.OSS_BUCKET,
};
const client = new OSS(ossConfig);

export const upload = async (file: Express.Multer.File): Promise<string> => {
  const stream = new Duplex();
  stream.push(file.buffer);
  stream.push(null);
  // 文件名 hash 化处理
  const fileName = hash(file.originalname) + path.extname(file.originalname);
  await client.putStream(fileName, stream);
  const url = `http://${ossConfig.bucket}.${ossConfig.region}.aliyuncs.com/${fileName}`;
  return url;
};

除了图床上传部分,其他代码基本上是大同小异的,重点在于文件信息的接收处理。

部署应用

我们可以使用 pm2 来进行应用的部署,首先要使用 npm run build 构建生产产物。

在生产产物构建完成后,在当前项目目录下执行下面这个命令即可运行项目。

pm2 start npm --name "webapi-travel" -- run start:prod

然后,就可以看到我们的项目成功启动了(如下图)。

nest 怎么返回状态 nest设置步骤_node.js_07

大家可以通过 https://webapi-travel.jt-gmall.com 进行访问,这是我部署后的站点地址。

小结

本篇文章没有过于仔细的探讨每一行代码的实现,只是很简单粗暴的将 nest 文档中的简单案例,放到实际场景中去看对应的处理方式。

当然还会有更好的处理方式,比如鉴权那块就可以有更好的处理方式。

这里就不做展开介绍了,感兴趣的童鞋可以自己去研究一下。