进阶全栈之路之 nest 篇(一)

Nest + TypeScript + TypeOrm + JWT

: 个人觉得 JavaScript 最大优势是灵活,最大的缺点也是灵活。开发速度快,但是调试和维护花费的时间会比强类型语言花的时间多很多,运行时报错,是我觉得它作为后端语言很大的一个问题,开发时跨文件调用 IDE 的函数以及变量提示,以及类型的限定也是我觉得JS的一些开发问题。这些问题在Typescript得到了很好的解决,加上面向对象的东西能在TS上实现,其实基础的东西在node上都能做了。

由于公司目前的技术栈是js, 后端在node.js 中用的比较多的服务端开发框架是,egg、nest、 koa、express等。

在之前的项目中,公司是采用的是egg,也研究了一些上ts的方式。但是由于项目之前存在比较多的问题,准备重构之前的代码。对,我就是在坚定的推动TS的那个人。

egg 对ts的支持不是很好,对于TS的支持,阿里在egg的基础上有 midway,个人写了下demo感觉不是很那啥,可能还在开发中吧,喜欢的朋友可以支持下哦。所以我放弃了原先的egg。

在node 中选择TS的框架,选择了Nest.js,下面列举nest我认为比较好一点。

Nest的优势:
  • Nest 类似于java中的 Spring Boot ,吸取了很多优秀的思想和想法,有想学习spring boot的前端同学,可以从这个搞起。对于这种后端过来的全栈比较容易就能上手。
  • egg star(目前为止) : 15.7K,而 nest 有28.1k
  • egg 有的, nest 基本上都有。
  • Nest 面对切面,对面对对象和面向切面支持的非常好。
  • 依赖注入容器(midway也是这种形式)
Nest的劣势:
  • 国内用的人不多,但是我发现国内也有很多人在搞。

好了废话,不多说,上教学地址:https://github.com/liangwei0101/Nest-Base-Project

生命周期

QQ图片20200624183631.png
  1. 当客户端一个Http请求到来时,首先过的中间件。
  2. 再是过的守卫(守卫只有通过和不通过)。
  3. 拦截器(这里我们可以看到,我们在执行函数前后都能做某些事情,统一的返回格式等等)。
  4. 管道,我们可以做参数校验和值的转换。
  5. 最后才会到Controller,然后就返回给客户端数据。

这里是我的项目的目录结构,大家也可以不按这个来。同层级的只列出部分,详细请看代码。

project
├── src(所有的ts源码都在这里)
│   ├── common (通用的一个目录)
│   │   └── class(通用类的集合)
│   │   │      └── xxx.ts(这个看业务吧)
│   │   └── decorator(自定义装饰器集合)
│   │   │      └── pagination.ts(自定义分页装饰器)
│   │   └── enum(枚举型集合)
│   │   │      └── apiErrorCode.ts(api错误集合)
│   │   └── globalGuard(全局守卫)
│   │   │      └── apiErrorCode.ts(api错误集合)
│   │   └── httpHandle(Http的处理)
│   │   │      └── httpException.ts(http异常统一处理)
│   │   └── interceptor(拦截器处理)
│   │   │      └── httpException.ts(http异常统一处理)
│   │   └── interface(接口集合)
│   │   │      └── xxx.ts(通用的接口)
│   │   └── middleware(中间件)
│   │   │      └──logger.middleware.ts(日志中间件)
│   │   └── pipe(管道)
│   │   │      └──validationPipe.ts(管道验证全局设置)
│   │   └── pipe(管道)
│   │   │      └──validationPipe.ts(管道验证全局设置)
│   │   └── specialModules(特殊模块)
│   │   │      └── auth(认证模块模块)
│   │   │      └── database(数据库模块)
│   │   └── utils(工具目录层)
│   │   │      └── stringUtil.ts(字符串工具集合)
│   ├── config(配置文件集合)
│   │   └── dev(dev配置)
│   │   │      └── database(数据库配置)
│   │   │      └── development.ts(配置引入出)
│   │   └── prod(prod配置)
│   │   │      └── (同上)
│   │   └── staging(staging配置)
│   │   │      └── (同上)
│   │   └── unitTest(unitTest配置)
│   │   │      └── (同上)
│   ├── entity(数据库表集合)
│   │   └── user.entity.ts(用户表)
│   ├── modules(模块的集合)
│   │   └── user(用户模块)
│   │   │      └── user.controller.ts(controller)
│   │   │      └── user.module.ts(module声明)
│   │   │      └── user.service.ts(service)
│   │   │      └── user.service.spec.ts(service 测试)
│   │   │      └── userDto.ts(user Dto验证)
│   ├── app.module.ts
│   ├── main.ts(代码运行入口)
├── package.json
├── tsconfig.json
└── tslint.json

Controller 层

Controller 和常规的spring boot的 Controller 或者egg之类的是一样的。就是接收前端的请求层。建议:业务不要放在 Controller 层,可以放在service层。如果service文件过大,可以采用namespace的方式进行文件拆分。

@Controller()   // 这里是说明这是一个Controller层
export class UserController {
 // 这里是相当于new userService(),但是容器会帮助你处理一些依赖关系。这里是学习spring的思想
  constructor(private readonly userService: UserService) {}
    
  // 这里就说这是一个get请求,具体的这种看下文件就会了
  // 在上面的声明周期里面
  @Get()
  getHello(@Body() createCatDto: CreateCatDto): string {
    console.log(createCatDto)
    return this.appService.getHello();
  }
}

Service 层

Service 层我这边是做的是一些业务的处理层,所以Controller 层的默认的.spec.ts测试文件,我是删掉的,因为,我的单元测试是在xx.service.spec.ts 中。

@Injectable()
export class UserService {
  // 这里是一个数据User表操作的Repository,通过注解的方式,由容器创建和销毁
  constructor(@InjectRepository(User) private usersRepository: Repository<User>) {
  }

  /**
   * 创建用户
   */
  async createUser() {
    const user = new User();
    user.userSource = '123456';
    user.paymentPassword = '123';
    user.nickname = '梁二狗';
    user.verifiedName = '梁二狗';
    const res = await this.usersRepository.save(user);
    return res;
  }
}

Service 单元测试

  • 单元测试分两种,一种是连接数据库的测试,一种是mock数据,测试逻辑是否正确的测试。这里先展示mock的。
const user = {
  "id": "2020-0620-1525-45106",
  "createTime": "2020-06-20T07:25:45.116Z",
  "updateTime": "2020-06-20T07:25:45.116Z",
  "phone": "18770919134",
  "locked": false,
  "role": "300",
  "nickname": "梁二狗",
  "verifiedName": "梁二狗",
}
describe('user.service', () => {
  let service: UserService;
  let repo: Repository<User>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserService,
        {
          provide: getRepositoryToken(User),
          useValue: {
            // 这里mock掉数据函数中涉及到的数据库的CURD
            create: jest.fn().mockResolvedValue(user),
            save: jest.fn().mockResolvedValue(user),
            findOne: jest.fn().mockResolvedValue(user),
          },
        },
      ],
    }).compile();
    service = module.get<UserService>(UserService);
    repo = module.get<Repository<User>>(getRepositoryToken(User));
  });
  // 测试逻辑的话,大概就是这个意思,
  it('createUser', async () => {
    const user = await service.createUser();
    expect(user.phone).toEqual('18770919134');
  });
}

这里有一个国外大佬写的测试,还蛮全的,有需要的可以看看:https://github.com/Zhao-Null/nest.js-example

DTO (数据库传输对象)

这个也不是java里面的独有的名词,DTO是数据库传输对象,所以,在我们前端传输数据过来的时候,我们需要校验和转换成数据库表对应的值,然后去save。
这里讲解下nest的DTO,在Controller处理前,我们需要校验参数是否正确,比如,我们需要某个参数,而前端没有传递,或者传递类型不对。

// 设置全局验证管道
@Injectable()
export class ValidationPipeConfig 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 errorMessageList = []
      const errorsObj = errors[0].constraints
      for (const key in errorsObj) {
        if (errorsObj.hasOwnProperty(key)) {
          errorMessageList.push(errorsObj[key])
        }
      }
      throw new CustomException(errorMessageList, HttpStatus.BAD_REQUEST);
    }
    return value;
  }

  private toValidate(metatype: any): boolean {
    const types = [String, Boolean, Number, Array, Object];
    return !types.find((type) => metatype === type);
  }
}

// 全局使用管道
app.useGlobalPipes(new ValidationPipeConfig());
// 创建用户dto
export class CreateUserDto {

  @IsNotEmpty({ message: 'account is null' })
  @IsString({ message: 'account is to require' })
  account: string;

  @IsNotEmpty({ message: 'name is null' })
  @IsString({ message: 'name is not null and is a string' })
  name: string;
}
// Controller 中  使用dto(当然要记得注册先,稍后讲解全局注册)
  @Post('/dto')
  async createTest(@Body() createUserDto: CreateUserDto) {
    console.log(createUserDto)
    return true;
  }

例如 account字段 在前端传递的参数为空时,或者类型不对时,将会返回 [ "account is null", "account is to require" ],这些个错误。这种防止到业务层做过多的判断,减少很多事情。当然,这里也是支持转化的,比如 字符串 "1" 转成数字 1,这种的,详情请看链接:https://docs.nestjs.com/pipes

全局超时时间

设置全局的超时时间,当请求超过某个设定时间时,将会返回超时。

  //main.ts 
  // 全局使用超时拦截
  app.useGlobalInterceptors(new TimeoutInterceptor());
/**
* 您想要处理路线请求的超时。当您的端点在一段时间后没有返回任何内容时,
* 您希望以错误响应终止。以下结构可实现此目的
* 10s 超时
*/
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  public intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(timeout(10000));
  }
}

全局成功返回格式

统一返回的格式,方便统一处理数据和错误。

import { Injectable, NestInterceptor, CallHandler, ExecutionContext } from '@nestjs/common';
import { map, switchMap } from 'rxjs/operators';
import { Observable } from 'rxjs';

interface Response<T> {
  data: T;
}

/**
 * 封装正确的返回格式
 * {
 *  data,
 *  code: 200,
 *  message: 'success'
 * }
 */
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler<T>): Observable<Response<T>> {
    return next.handle().pipe(
      map(data => {
        return {
          data,
          code: 200,
          message: 'success',
        };
      }),
    );
  }
}

全局成功异常的格式

这里分自定义异常和其它异常,自定义将会返回自定义异常的状态码和系统。而其它异常将会返回异常和,系统返回的错误。

import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { CustomException } from './customException';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();
    let errorResponse: any;
    const date = new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString();

    if (exception instanceof CustomException) {
      // 自定义异常
      errorResponse = {
        code: exception.getErrorCode(), // 错误code
        errorMessage: exception.getErrorMessage(),
        message: 'error',
        url: request.originalUrl, // 错误的url地址
        date: date,
      };
    } else {
      // 非自定义异常
      errorResponse = {
        code: exception.getStatus(), // 错误code
        errorMessage: exception.message,
        url: request.originalUrl, // 错误的url地址
        date: date,
      };
    }
    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;
    // 设置返回的状态码、请求头、发送错误信息
    response.status(status);
    response.header('Content-Type', 'application/json; charset=utf-8');
    response.send(errorResponse);
  }
}

JWT的封装

官网的jwt的例子,在每个函数如果需要接口校验都需要加 @UseGuards(AuthGuard()) 相关的注解,但是大部分接口都是需要接口验证的。所以这里我选择了自己封装一个。

这里我有写2种方式,如果有适合自己的,请选择。

  • 方式1:自己封装一个注解。
    这里是我们重写的本地校验类的名称,继承于AuthGuard
///auth.local.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
// 自定义校验
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') { }

这里是我们的JWT校验类的名称,继承于AuthGuard

///jwt.auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { }
/// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }

  async validate(payload: any) {
    return { userId: payload.account, password: payload.password };
  }
}

这里抛出了一个自定义异常,在上面有写的。

/// local.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
import { CustomException } from '../../../httpHandle/customException';
import { ApiError } from '../../../enum/apiErrorCode';

/**
* 本地 验证
*/
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {

  /**
 * 这里的构造函数向父类传递了授权时必要的参数,在实例化时,父类会得知授权时,客户端的请求必须使用 Authorization 作为请求头,
 * 而这个请求头的内容前缀也必须为 Bearer,在解码授权令牌时,使用秘钥 secretOrKey: 'secretKey' 来将授权令牌解码为创建令牌时的 payload。
 */
  constructor(private readonly authService: AuthService) {
    super({
      usernameField: 'account',
      passwordField: 'password'
    });
  }

  /**
 * validate 方法实现了父类的抽象方法,在解密授权令牌成功后,即本次请求的授权令牌是没有过期的,
 * 此时会将解密后的 payload 作为参数传递给 validate 方法,这个方法需要做具体的授权逻辑,比如这里我使用了通过用户名查找用户是否存在。
 * 当用户不存在时,说明令牌有误,可能是被伪造了,此时需抛出 UnauthorizedException 未授权异常。
 * 当用户存在时,会将 user 对象添加到 req 中,在之后的 req 对象中,可以使用 req.user 获取当前登录用户。
 */
  async validate(account: string, password: string): Promise<any> {
    let user = await this.authService.validateUserAccount(account);
    if (!user) {
      throw new CustomException(
        ApiError.USER_IS_NOT_EXIST,
        ApiError.USER_IS_NOT_EXIST_CODE,
      );
    }

    user = await this.authService.validateUserAccountAndPasswd(account, password);
    if (!user) {
      throw new CustomException(
        ApiError.USER_PASSWD_IS_ERROR,
        ApiError.USER_PASSWD_IS_ERROR_CODE,
      );
    }
    return user;
  }
}

全局守卫,这里的核心就是,当我们去执行时,看有没有 no-auth 的注解,有的话,就直接跳过,不走默认的jwt和自定义(登录)校验。当然,我们也是在这里写相关的白名单哦。先看注解吧。

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';
import { IAuthGuard } from '@nestjs/passport';
import { JwtAuthGuard } from '../specialModules/auth/guards/jwt.auth.guard';
import { LocalAuthGuard } from '../specialModules/auth/guards/auth.local.guard';

@Injectable()
export class GlobalAuthGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) { }
  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {

    // 获取登录的注解
    const loginAuth = this.reflector.get<boolean>('login-auth', context.getHandler());

    // 在这里取metadata中的no-auth,得到的会是一个bool
    const noAuth = this.reflector.get<boolean>('no-auth', context.getHandler());
    if (noAuth) {
      return true;
    }

    const guard = GlobalAuthGuard.getAuthGuard(loginAuth);
    // 执行所选策略Guard的canActivate方法
    return guard.canActivate(context);
  }

  // 根据NoAuth的t/f选择合适的策略Guard
  private static getAuthGuard(loginAuth: boolean): IAuthGuard {
    if (loginAuth) {
      return new LocalAuthGuard();
    } else {
      return new JwtAuthGuard();
    }
  }
}

有 @NoAuth()的将不在进行任何校验,其他接口默认走JwtAuthGuard和 LocalAuthGuard校验

// 自定义装饰器
/**
* 登录认证
*/
export const LoginAuth = () => SetMetadata('login-auth', true);
/// user.controller.ts
@Get()
@NoAuth()
@ApiOperation({ description: '获取用户列表' })
async userList(@Paginations() paginationDto: IPagination) {
  return await this.userService.getUserList(paginationDto);
}
  • 方式2:就是在配置里头添加一个白名单列表,然后在守卫处判断。这个代码就不写了吧,不复杂的,随便搞搞就有了。

到这里基本的resetful接口和业务逻辑就能跑起来了,下节课讲解队列,graphql,等相关业务开发经常用到的东西,下次再见。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345