進階全棧之路之 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,等相關業務開發經常用到的東西,下次再見。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。