iT邦幫忙

2021 iThome 鐵人賽

DAY 30
0
Modern Web

NestJS 帶你飛!系列 第 30

[NestJS 帶你飛!] DAY30 - 實戰演練 (中)

  • 分享至 

  • xImage
  •  

本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!

API 設計

前面有提到這次實作的系統共有兩大資源,分別是:使用者 (user) 與 待辦事項 (todo),事實上,以 API 的角度來看會多一個資源,那就是登入與註冊的 身分驗證 (Authentication)

身分驗證

運用 Authentication 技巧來實作身分驗證。

提醒:Authentication 的技巧可以參考 DAY23 - Authentication (上)DAY24 - Authentication (下)

Guards

我們可以先把本地策略與 JWT 所用到的 Guard 進行包裝,在 src/core/guards 下新增 jwt-auth.guard.tslocal-auth.guard.ts

$ nest generate guard core/guards/jwt-auth
$ nest generate guard core/guards/local-auth

調整 JwtAuthGuard 的內容:

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

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

調整 LocalAuthGuard 的內容:

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

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

建立 index.ts 做匯出管理:

export { JwtAuthGuard } from './jwt-auth.guard';
export { LocalAuthGuard } from './local-auth.guard';

提醒:Guard 的功能可以參考 DAY13 - Guard

使用者模組

我們需要建立 UserModuleUserService 來提供我們取得使用者資訊的操作:

$ nest generate module features/user
$ nest generate service features/user

建立完成後,先不急著更動它們,回想一下我們前面在處理使用者密碼的時候,特別設計了一個方法來達成加密,我們先將其建立起來,在 src/core/utils 資料夾下 common.utility.ts

import { randomBytes, pbkdf2Sync } from 'crypto';

export class CommonUtility {
  public static encryptBySalt(
    input: string,
    salt = randomBytes(16).toString('hex'),
  ) {
    const hash = pbkdf2Sync(input, salt, 1000, 64, 'sha256').toString('hex');
    return { hash, salt };
  }
}

調整 UserModule 的內容,將 UserService 匯出並引入 MongooseModule 來建立 UserModel,進而操作 MongoDB 中使用者的 Collection,而需要帶入的 Definition 為 UserDefinition

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

import { UserDefinition } from '../../common/models/user.model';

import { UserService } from './user.service';

@Module({
  imports: [MongooseModule.forFeature([UserDefinition])],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

我們先將註冊使用者時會使用到的 CreateUserDto 建立起來,在 src/features/user/dto 資料夾下新增 create-user.dto.ts

import { IsEmail, IsEnum, MaxLength, MinLength } from 'class-validator';

import {
  USER_PASSWORD_MAX_LEN,
  USER_PASSWORD_MIN_LEN,
  USER_USERNAME_MAX_LEN,
  USER_USERNAME_MIN_LEN,
} from '../../../common/constants/user.const';
import { Role } from '../../../common/enums/role.enum';

export class CreateUserDto {
  @MinLength(USER_USERNAME_MIN_LEN)
  @MaxLength(USER_USERNAME_MAX_LEN)
  public readonly username: string;

  @MinLength(USER_PASSWORD_MIN_LEN)
  @MaxLength(USER_PASSWORD_MAX_LEN)
  public readonly password: string;

  @IsEmail()
  public readonly email: string;

  @IsEnum(Role)
  public readonly role: Role;
}

修改 UserService 的內容,透過 @InjectModel 裝飾器指定 USER_MODEL_TOKEN 來注入 UserModel

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';

import { USER_MODEL_TOKEN, UserDocument } from '../../common/models/user.model';

@Injectable()
export class UserService {
  constructor(
    @InjectModel(USER_MODEL_TOKEN)
    private readonly userModel: Model<UserDocument>,
  ) {}
}

我們這套系統的註冊 API 主要是用來註冊預設使用者,並透過該預設使用者來新增其他使用者,所以註冊 API 只在沒有任何使用者的情況下才能使用,故 UserService 不僅需要設計 createUser 方法來建立使用者,還需提供 hasUser 方法來確認是否有使用者資料在資料庫中,又因為登入需要進行身分核對,所以還需要設計 findUser 來取得對應的使用者資料:

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model } from 'mongoose';

import { CommonUtility } from '../../core/utils';

import { USER_MODEL_TOKEN, UserDocument } from '../../common/models/user.model';

import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UserService {
  constructor(
    @InjectModel(USER_MODEL_TOKEN)
    private readonly userModel: Model<UserDocument>,
  ) {}

  public async createUser(user: CreateUserDto) {
    const { username, email, role } = user;
    const password = CommonUtility.encryptBySalt(user.password);
    const document = await this.userModel.create({
      username,
      password,
      email,
      role,
    });
    return document?.toJSON();
  }

  public async findUser(filter: FilterQuery<UserDocument>, select?: any) {
    const query = this.userModel.findOne(filter).select(select);
    const document = await query.exec();
    return document?.toJSON();
  }

  public async hasUser() {
    const count = await this.userModel.estimatedDocumentCount().exec();
    return count > 0;
  }
}

建立 index.ts 來做匯出管理:

export { UserModule } from './user.module';
export { UserService } from './user.service';
export { CreateUserDto } from './dto/create-user.dto';

驗證模組

有了 UserService 讓我們可以操作使用者資料之後,就要來實作驗證的部分了,需要建立相關元件:

$ nest generate module features/auth
$ nest generate service features/auth
$ nest generate controller features/auth

還記得前面有提過的 Passport 嗎?當驗證程序通過之後,會將部分資料放入請求物件中,讓後續的操作可以使用該資料,我們稱它為 載體 (Payload),通常會將部分使用者資訊放入載體中,所以我們要在 src/features/auth/interfaces 資料夾下新增 payload.interface.ts 來將我們的載體定義好介面,主要包含的資料為 idusernamerole

import { Role } from '../../../common/enums/role.enum';

export interface UserPayload {
  id: string;
  username: string;
  role: Role;
}

我們還可以設計裝飾器來取得載體內容,在 src/features/auth/decorators 資料夾下新增 payload.decorator.ts

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const User = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const request: Express.Request = context.switchToHttp().getRequest();
    return request.user;
  },
);

調整一下 AuthModule 的配置,我們需要運用 PassportModuleJwtModule 來完成一個完整的登入與註冊的身分驗證機制,所以將它們引入並從 ConfigService 取得相關環境變數,另外,還需要引入 UserModule 讓我們可以使用 UserService 來操作使用者資料:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';

import { UserModule } from '../user';

import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';

@Module({
  imports: [
    PassportModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => {
        const secret = config.get('secrets.jwt');
        return {
          secret,
          signOptions: {
            expiresIn: '3600s',
          },
        };
      },
    }),
    UserModule,
  ],
  providers: [AuthService],
  controllers: [AuthController],
})
export class AuthModule {}

接著,調整 AuthService 的內容,在裡面設計 validateUser 來驗證是否為合法的使用者,以及設計 generateJwt 來產生 JWT 讓使用者可以透過該 token 存取資源:

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

import { CommonUtility } from '../../core/utils/common.utility';

import { UserPayload } from './interfaces/payload.interface';

import { UserService } from '../user';

@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
    private readonly jwtService: JwtService,
  ) {}

  public async validateUser(username: string, password: string) {
    const user = await this.userService.findUser({ username });
    const { hash } = CommonUtility.encryptBySalt(
      password,
      user?.password?.salt,
    );
    if (!user || hash !== user?.password?.hash) {
      return null;
    }
    return user;
  }

  public generateJwt(payload: UserPayload) {
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

完成 AuthService 以後,我們要把相關的驗證策略建立起來,這樣才能完整走完 Passport 的驗證機制,首先,我們建立 LocalStrategy 來處理登入時使用的驗證策略,在 src/features/auth/strategies 資料夾下新增 local.strategy.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';

import { UserPayload } from '../interfaces/payload.interface';

import { AuthService } from '../auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super();
  }

  public async validate(username: string, password: string) {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    const payload: UserPayload = {
      id: user._id,
      username: user.username,
      role: user.role,
    };
    return payload;
  }
}

有登入使用的驗證策略之後,還需要設計登入期間的驗證策略,也就是針對 JWT 的驗證,在 src/features/auth/strategies 資料夾下新增 jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';

import { ExtractJwt, Strategy } from 'passport-jwt';

import { UserPayload } from '../interfaces/payload.interface';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('secrets.jwt'),
    });
  }

  validate(payload: UserPayload) {
    return payload;
  }
}

設計好驗證策略後,需要將它們添加到 AuthModule 下的 providers

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';

import { UserModule } from '../user';

import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';

import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';

@Module({
  imports: [
    PassportModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => {
        const secret = config.get('secrets.jwt');
        return {
          secret,
          signOptions: {
            expiresIn: '3600s',
          },
        };
      },
    }),
    UserModule,
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  controllers: [AuthController],
})
export class AuthModule {}

最後,就是調整 AuthController 的內容,設計 signupsignin 來實作註冊與登入的效果:

import {
  Body,
  Controller,
  ForbiddenException,
  Post,
  UseGuards,
} from '@nestjs/common';

import { LocalAuthGuard } from '../../core/guards';

import { CreateUserDto, UserService } from '../user';

import { User } from './decorators/payload.decorator';
import { UserPayload } from './interfaces/payload.interface';

import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly userService: UserService,
  ) {}

  @Post('/signup')
  async signup(@Body() dto: CreateUserDto) {
    const hasUser = await this.userService.hasUser();
    if (hasUser) {
      throw new ForbiddenException();
    }
    const user = await this.userService.createUser(dto);
    const { _id: id, username, role } = user;
    return this.authService.generateJwt({ id, username, role });
  }

  @UseGuards(LocalAuthGuard)
  @Post('/signin')
  signin(@User() user: UserPayload) {
    return this.authService.generateJwt(user);
  }
}

建立 index.ts 做匯出管理:

export { AuthModule } from './auth.module';
export { UserPayload } from './interfaces/payload.interface';
export { User } from './decorators/payload.decorator';

使用者

預計會設計以下幾個使用者相關的 API:

  • [GET] /users:取得使用者列表。
  • [POST] /users:新增使用者。
  • [DELETE] /users/:id:刪除特定使用者。
  • [PATCH] /users/:id:更新特定使用者。

由於有更新使用者的部分,所以還需要設計對應的 DTO,在 src/features/user/dto 資料夾下新增 update-user.dto.ts,並使用 PartialType 繼承 CreateUserDto 的屬性:

import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

export class UpdateUserDto extends PartialType(CreateUserDto) {}

另外,取得使用者列表的部分應當給予單次取得的上限值以及要跳過幾筆資料,故需要設計一個 SearchDto 來定義相關的參數,在 src/core/bases 資料夾下新增 search.dto.ts

import { IsOptional } from 'class-validator';

export class SearchDto {
  @IsOptional()
  skip?: number;

  @IsOptional()
  limit?: number;
}

建立 index.ts 做匯出管理:

export { SearchDto } from './search.dto';

注意:會將 SearchDto 放在 core/bases 資料夾下是為了讓其他 API 有更多的擴充空間,讓它們的 DTO 來繼承。

可以透過設計 Pipe 來將輸入進來的 limitskip 限制在合理的範圍內以及預設值,在 src/core/pipes 資料夾下新增 search.pipe.ts

import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';

@Injectable()
export class SearchPipe implements PipeTransform<Record<string, any>> {
  private readonly DEFAULT_LIMIT = 30;
  private readonly MAX_LIMIT = 50;
  private readonly DEFAULT_SKIP = 0;

  transform(value: Record<string, any>, metadata: ArgumentMetadata) {
    const { limit, skip } = value;
    value.limit = this.setLimit(parseInt(limit));
    value.skip = this.setSkip(parseInt(skip));
    return value;
  }

  private setLimit(limit: number): number {
    if (!limit) {
      return this.DEFAULT_LIMIT;
    }
    if (limit > this.MAX_LIMIT) {
      return this.MAX_LIMIT;
    }
    return limit;
  }

  private setSkip(skip: number): number {
    if (!skip) {
      return this.DEFAULT_SKIP;
    }
    return skip;
  }
}

建立 index.ts 做匯出管理:

export { SearchPipe } from './search.pipe';

接著,修改 UserService 的內容,新增 findUsers 來取得使用者列表、deleteUser 來刪除使用者、 updateUser 來更新使用者以及用來檢查是否有重複註冊使用者的 existUser

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model } from 'mongoose';

import { CommonUtility } from '../../core/utils';
import { SearchDto } from '../../core/bases/dto';

import { USER_MODEL_TOKEN, UserDocument } from '../../common/models/user.model';

import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Injectable()
export class UserService {
  constructor(
    @InjectModel(USER_MODEL_TOKEN)
    private readonly userModel: Model<UserDocument>,
  ) {}

  public async createUser(user: CreateUserDto) {
    const { username, email, role } = user;
    const password = CommonUtility.encryptBySalt(user.password);
    const document = await this.userModel.create({
      username,
      password,
      email,
      role,
    });
    return document?.toJSON();
  }

  public async findUser(filter: FilterQuery<UserDocument>, select?: any) {
    const query = this.userModel.findOne(filter).select(select);
    const document = await query.exec();
    return document?.toJSON();
  }

  public async findUsers(search: SearchDto, select?: any) {
    const { skip, limit } = search;
    const query = this.userModel.find().select(select);
    const documents = await query.skip(skip).limit(limit).exec();
    return documents.map((document) => document?.toJSON());
  }

  public async deleteUser(userId: string) {
    const document = await this.userModel.findByIdAndRemove(userId).exec();
    if (!document) {
      return;
    }
    return {};
  }

  public async updateUser(userId: string, data: UpdateUserDto, select?: any) {
    const obj: Record<string, any> = { ...data };
    if (obj.password) {
      obj.password = CommonUtility.encryptBySalt(obj.password);
    }
    const query = this.userModel
      .findByIdAndUpdate(userId, obj, { new: true })
      .select(select);
    const document = await query.exec();
    return document?.toJSON();
  }

  public existUser(filter: FilterQuery<UserDocument>) {
    return this.userModel.exists(filter);
  }

  public async hasUser() {
    const count = await this.userModel.estimatedDocumentCount().exec();
    return count > 0;
  }
}

透過 CLI 產生 UserController

$ nest generate controller features/user

修改 UserController 的內容以符合我們的 API 需求:

import {
  Body,
  ConflictException,
  Controller,
  Delete,
  ForbiddenException,
  Get,
  Param,
  Patch,
  Post,
  Query,
  UseGuards,
} from '@nestjs/common';

import { SearchPipe } from '../../core/pipes';
import { JwtAuthGuard } from '../../core/guards';
import { SearchDto } from '../../core/bases/dto';

import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

import { UserService } from './user.service';

@UseGuards(JwtAuthGuard)
@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get()
  async getUsers(@Query(SearchPipe) query: SearchDto) {
    return this.userService.findUsers(query, '-password');
  }

  @Post()
  async createUser(@Body() dto: CreateUserDto) {
    const { username, email } = dto;
    const exist = await this.userService.existUser({
      $or: [{ username }, { email }],
    });

    if (exist) {
      throw new ConflictException('username or email is already exist.');
    }

    const user = await this.userService.createUser(dto);
    const { password, ...result } = user;
    return result;
  }

  @Delete(':id')
  async deleteUser(@Param('id') id: string) {
    const response = await this.userService.deleteUser(id);
    if (!response) {
      throw new ForbiddenException();
    }
    return response;
  }

  @Patch(':id')
  async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) {
    const user = await this.userService.updateUser(id, dto, '-password');
    if (!user) {
      throw new ForbiddenException();
    }
    return user;
  }
}

待辦事項

透過 CLI 快速產生相關元件:

$ nest generate module features/todo
$ nest generate service features/todo
$ nest generate controller features/todo

預計會設計以下幾個待辦事項相關的 API:

  • [GET] /todos:取得待辦事項列表。
  • [POST] /todos:新增待辦事項。
  • [DELETE] /todos/:id:刪除特定待辦事項。
  • [PATCH] /todos/:id:更新特定待辦事項。

由於有新增與更新待辦事項的部分,所以還需要設計對應的 DTO,在 src/features/todo/dto 資料夾下新增 create-todo.dto.tsupdate-todo.dto.ts

import { IsOptional, MaxLength, MinLength } from 'class-validator';
import {
  TODO_DESCRIPTION_MAX_LEN,
  TODO_TITLE_MAX_LEN,
  TODO_TITLE_MIN_LEN,
} from '../../../common/constants/todo.const';

export class CreateTodoDto {
  @MinLength(TODO_TITLE_MIN_LEN)
  @MaxLength(TODO_TITLE_MAX_LEN)
  public readonly title: string;

  @IsOptional()
  @MaxLength(TODO_DESCRIPTION_MAX_LEN)
  public readonly description?: string;

  @IsOptional()
  public readonly completed?: boolean;
}
import { PartialType } from '@nestjs/mapped-types';

import { CreateTodoDto } from './create-todo.dto';

export class UpdateTodoDto extends PartialType(CreateTodoDto) {}

TodoModule 中引入 MongooseModule 來建立 TodoModel,進而操作 MongoDB 中待辦事項的 Collection,而需要帶入的 Definition 為 TodoDefinition

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

import { TodoDefinition } from '../../common/models/todo.model';

import { TodoController } from './todo.controller';
import { TodoService } from './todo.service';

@Module({
  imports: [MongooseModule.forFeature([TodoDefinition])],
  controllers: [TodoController],
  providers: [TodoService],
})
export class TodoModule {}

接著,調整 TodoService 的內容,我們要設計 createTodo 新增待辦事項、findTodos 取得待辦事項列表、deleteTdod 刪除特定待辦事項、updateTodo 更新特定待辦事項:

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';

import { SearchDto } from '../../core/bases/dto';

import { TodoDocument, TODO_MODEL_TOKEN } from '../../common/models/todo.model';

import { CreateTodoDto } from './dto/create-todo.dto';
import { UpdateTodoDto } from './dto/update-todo.dto';

@Injectable()
export class TodoService {
  constructor(
    @InjectModel(TODO_MODEL_TOKEN)
    private readonly todoModel: Model<TodoDocument>,
  ) {}

  public async createTodo(data: CreateTodoDto) {
    const todo = await this.todoModel.create(data);
    return todo?.toJSON();
  }

  public async findTodos(search: SearchDto, select?: any) {
    const { skip, limit } = search;
    const query = this.todoModel.find().select(select);
    const documents = await query.skip(skip).limit(limit).exec();
    return documents.map((document) => document?.toJSON());
  }

  public async deleteTodo(todoId: string) {
    const document = await this.todoModel.findByIdAndRemove(todoId).exec();
    if (!document) {
      return;
    }
    return {};
  }

  public async updateTodo(todoId: string, data: UpdateTodoDto, select?: any) {
    const query = this.todoModel
      .findByIdAndUpdate(todoId, data, { new: true })
      .select(select);
    const document = await query.exec();
    return document?.toJSON();
  }
}

最後,調整 TodoController 的內容以符合我們的 API 需求:

import {
  Body,
  Controller,
  Delete,
  ForbiddenException,
  Get,
  Param,
  Patch,
  Post,
  Query,
  UseGuards,
} from '@nestjs/common';

import { JwtAuthGuard } from '../../core/guards';
import { SearchPipe } from '../../core/pipes';
import { SearchDto } from '../../core/bases/dto';

import { CreateTodoDto } from './dto/create-todo.dto';
import { UpdateTodoDto } from './dto/update-todo.dto';

import { TodoService } from './todo.service';

@UseGuards(JwtAuthGuard)
@Controller('todos')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}

  @Get()
  async getTodos(@Query(SearchPipe) query: SearchDto) {
    return this.todoService.findTodos(query);
  }

  @Post()
  async createTodo(@Body() dto: CreateTodoDto) {
    return this.todoService.createTodo(dto);
  }

  @Delete(':id')
  async deleteTodo(@Param('id') id: string) {
    const response = await this.todoService.deleteTodo(id);
    if (!response) {
      throw new ForbiddenException();
    }
    return response;
  }

  @Patch(':id')
  async updateTodo(@Param('id') id: string, @Body() dto: UpdateTodoDto) {
    const todo = await this.todoService.updateTodo(id, dto);
    if (!todo) {
      throw new ForbiddenException();
    }
    return todo;
  }
}

小結

今天我們把所有資源的 API 都實作完畢了,包括:authtodosusers。今天篇幅較長可能耗費了各位讀者們許多精力,加油!只剩下角色授權驗證的部分了,明天再衝刺一天吧!


上一篇
[NestJS 帶你飛!] DAY29 - 實戰演練 (上)
下一篇
[NestJS 帶你飛!] DAY31 - 實戰演練 (下)
系列文
NestJS 帶你飛!32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言