Nest 快速通关攻略

写在开头

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

您的 点赞、收藏、转发 是对作者的最大鼓励,也可以让更多人看到本篇文章,万分感谢!

如果觉得本文对您有帮助,请帮忙在 github 上点亮 star 鼓励一下吧!

正文

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

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

image

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

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

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

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

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

初始化项目

本案例的源码仓库在 源码地址 可下载。

扫描二维码关注公众号,回复: 13769741 查看本文章

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

nest new webapi-travel
复制代码

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

image

如上图所示,进入页面看到 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 使用了联合索引,能够有效减少回表次数,并且能够将图片进行排序。创建完成后,可以检查一下。(如下图)

image

配置数据库连接

在数据表初始化完成后,我们需要在 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 验证一下我们的服务。(如下图)

image

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

定义数据实体

数据实体在 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
}
复制代码

image

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

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

首先,还是定义好对应的 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 的核心内容,所以这里就不做展开介绍了,感兴趣的童鞋可以找到 源码地址 进行阅读。

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

image

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

文件上传

最后,再对一些衍生知识点进行介绍,比如使用 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
复制代码

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

image

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

小结

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

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

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

本篇文章旨在提供 nest 场景实战案例快速参考。

大家有什么感兴趣的场景也可以列出来,我会选一些典型的场景在文章中继续补充。

猜你喜欢

转载自juejin.im/post/7083049343577505823
今日推荐