三合一(代码优先):NestJs & GraphQl & Mongoose 问题解决方案实施结论

2025-06-11

三合一(代码优先):NestJs & GraphQl & Mongoose

问题

解决方案

执行

结论

在Twitter上关注我,很高兴接受您的建议和改进。

我在完成一个个人项目后决定写这篇博文。在开始这个项目时,我思考了如何建立一个可靠的架构,使用NestJsMongoose来创建GraphQL API 。

为什么选择 GraphQL?

  • 不再过度获取或获取不足;
  • 前端产品快速迭代;
  • 后端的深入分析;
  • 模式和类型系统的好处;

--如何使用 GraphQL

为什么选择 NestJs?

  • Nest 在这些常见的 Node.js 框架(Express/Fastify)之上提供了更高的抽象级别,并将它们的 API 直接暴露给开发者。这使得开发者可以自由地使用底层平台提供的大量第三方模块。

——菲利普·马宗

为什么选择 Mongoose?

  • MongooseJS 在 MongoDB 之上提供了一个抽象层,从而无需使用命名集合。
  • Mongoose 中的模型执行了大部分设置文档属性默认值和验证数据的工作。
  • MongooseJS 中,函数可以附加到模型上。这样可以无缝地集成新功能。
  • 查询使用函数链而不是嵌入式助记符,这使得代码更加灵活、可读性更强,因此也更易于维护。

——吉姆·梅德洛克


问题

当我开始构建项目架构时,总会遇到一个问题:数据模型的定义,以及应用程序的不同层如何使用它。就我而言,为应用程序的不同层定义数据模型让我有些恼火:

  • 为 GraphQL 定义模式以实现 API 端点;
  • 为 Mongoose 定义一个模式来组织数据库的文档;
  • 定义一个数据模型以便应用程序映射对象;

解决方案

理想情况下,只需定义一次数据模型,即可用于生成 GraphQL 模式、MongoDB 集合模式以及 NestJs 提供程序使用的类。神奇的是,NestJs 及其插件让我们可以轻松做到这一点😙。

NestJs 插件

NestJS 插件将不同的技术封装在NestJS 模块中,以便于使用和集成到 NestJS 生态系统中。在本例中,我们将使用以下两个插件@nestjs/mongoose@nestjs/graphql

这两个插件允许我们以两种方式进行:

  • schema-first:首先,定义 Mongoose 和 GraphQL 的模式,然后使用它来生成我们的 typescript 类。
  • 代码优先:首先,定义我们的 typescript 类,然后使用它们生成我们的模式 Mongoose/G​​raphQL。

我使用了代码优先方法,因为它允许我实现单一模型(typescript 类)并使用它来生成 GraphQL 和 Mongoose 👌 的模式。


执行

这是GitHub 上的最终项目源代码

好了,说了这么多。热热手指,来玩玩魔术吧🧙!

NestJS

首先,使用 创建我们的 NestJs 项目@nestjs/cli。我们将其命名为three-in-one-project

$ npm i -g @nestjs/cli
$ nest new three-in-one-project
Enter fullscreen mode Exit fullscreen mode

这将启动我们的 NestJs 项目:

替代文本

这里我们感兴趣的是文件夹的内容src/

  • main.ts:NestJS 应用程序的入口点,我们在此引导它。
  • app.module.ts:NestJS 应用的根模块。它实现了controller AppControllerprovider AppService

要为嵌套服务器运行:

$ npm start
Enter fullscreen mode Exit fullscreen mode

替代文本

为了更好地组织,我们将AppModule文件放在专门的文件夹中,并更新的导入src/app/路径AppModulemain.ts

替代文本

不要忘记更新导入AppModule路径main.ts

模型

我们将创建一个 API 来管理Peron拥有列表的人员列表,Hobby为此我们将在app/文件夹中创建这两个模型:

// person.model.ts

export class Person {
  _id: string;
  name: string;
  hobbies: Hobby[];
}
Enter fullscreen mode Exit fullscreen mode
// hobby.model.ts

export class Hobby {
  _id: string;
  name: string;
}
Enter fullscreen mode Exit fullscreen mode

猫鼬

安装依赖项

从这两个类Hobby和中,Person我们将生成相应的 mongoose 模式HobbySchema和。为此,我们将安装带有一些其他依赖项的PersonSchema包:@nestjs/mongoose

$ npm i @nestjs/mongoose mongoose
$ npm i --save-dev @types/mongoose
Enter fullscreen mode Exit fullscreen mode

连接到 MongoDB

为了将我们的后端连接到 mongoDB 数据库,我们将AppModule导入MongooseModule

// app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MongooseModule } from '@nestjs/mongoose';

@Module({
  // "mongodb://localhost:27017/three-in-one-db" is the connection string to the project db
  imports: [
    MongooseModule.forRoot('mongodb://localhost:27017/three-in-one-db'),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

为了更好地组织,我们将创建两个子模块PersonModuleHobbyModule每个子模块管理相应的模型。我们将使用以下方法@nestjs/cli快速完成此操作:

$ cd src\app\
$ nest generate module person
$ nest generate module hobby
Enter fullscreen mode Exit fullscreen mode

创建的模块PersonModuleHobbyModule会自动导入AppModule

person.model.ts现在,将每个文件移动hobby.model.ts到其相应的模块中:

替代文本

模式生成

现在我们可以开始设置 mongoose 模式的生成。@nestjs/mongoose为我们提供了一个装饰器来注释我们的 typescript 类,以指示如何生成 mongoose 模式。让我们为我们的类添加一些装饰器:

// person.model.ts

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Schema as MongooseSchema } from 'mongoose';

import { Hobby } from '../hobby/hobby.model';

@Schema()
export class Person {
  _id: MongooseSchema.Types.ObjectId;

  @Prop()
  name: string;

  @Prop()
  hobbies: Hobby[];
}

export type PersonDocument = Person & Document;

export const PersonSchema = SchemaFactory.createForClass(Person);
Enter fullscreen mode Exit fullscreen mode
// hobby.model.ts

import { Document, Schema as MongooseSchema } from 'mongoose';
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';

@Schema()
export class Hobby {
  _id: MongooseSchema.Types.ObjectId;

  @Prop()
  name: string;
}

export type HobbyDocument = Hobby & Document;

export const HobbySchema = SchemaFactory.createForClass(Hobby);
Enter fullscreen mode Exit fullscreen mode
  • 装饰器@Prop()在文档中定义一个属性。
  • 装饰器@Schema()将类标记为模式定义
  • Mongoose 文档(例如PersonDocument:)表示与 MongoDB 中存储的文档的一对一映射。
  • MongooseSchema.Types.ObjectId是一种通常用于唯一标识符的猫鼬类型

最后,我们将模型导入到两个模块中的 MongooseModule 中:

// person.module.ts

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

import { Person, PersonSchema } from './person.model';

@Module({
  imports: [
    MongooseModule.forFeature([{ name: Person.name, schema: PersonSchema }]),
  ],
})
export class PersonModule {}
Enter fullscreen mode Exit fullscreen mode
// hobby.module.ts

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

import { Hobby, HobbySchema } from './hobby.model';

@Module({
  imports: [
    MongooseModule.forFeature([{ name: Hobby.name, schema: HobbySchema }]),
  ],
})
export class HobbyModule {}
Enter fullscreen mode Exit fullscreen mode

CRUD 操作

我们将为两个模块创建一个实现 CRUD 操作的层。为每个模块创建一个NestJS 服务来实现该操作。要创建这两个服务,请执行以下命令:

$ cd src\app\person\
$ nest generate service person --flat
$ cd ..\hobby\
$ nest generate service hobby --flat
Enter fullscreen mode Exit fullscreen mode

我们习惯--flat不为该服务生成文件夹。

替代文本

更新服务以添加 CRUD 方法及其输入类:

// person.service.ts

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Schema as MongooseSchema } from 'mongoose';

import { Person, PersonDocument } from './person.model';
import {
  CreatePersonInput,
  ListPersonInput,
  UpdatePersonInput,
} from './person.inputs';

@Injectable()
export class PersonService {
  constructor(
    @InjectModel(Person.name) private personModel: Model<PersonDocument>,
  ) {}

  create(payload: CreatePersonInput) {
    const createdPerson = new this.personModel(payload);
    return createdPerson.save();
  }

  getById(_id: MongooseSchema.Types.ObjectId) {
    return this.personModel.findById(_id).exec();
  }

  list(filters: ListPersonInput) {
    return this.personModel.find({ ...filters }).exec();
  }

  update(payload: UpdatePersonInput) {
    return this.personModel
      .findByIdAndUpdate(payload._id, payload, { new: true })
      .exec();
  }

  delete(_id: MongooseSchema.Types.ObjectId) {
    return this.personModel.findByIdAndDelete(_id).exec();
  }
}
Enter fullscreen mode Exit fullscreen mode
// person.inputs.ts

import { Schema as MongooseSchema } from 'mongoose';
import { Hobby } from '../hobby/hobby.model';

export class CreatePersonInput {
  name: string;
  hobbies: Hobby[];
}

export class ListPersonInput {
  _id?: MongooseSchema.Types.ObjectId;
  name?: string;
  hobbies?: Hobby[];
}

export class UpdatePersonInput {
  _id: MongooseSchema.Types.ObjectId;
  name?: string;
  hobbies?: Hobby[];
}
Enter fullscreen mode Exit fullscreen mode
// hobby.service.ts

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Schema as MongooseSchema } from 'mongoose';

import { Hobby, HobbyDocument } from './hobby.model';
import {
  CreateHobbyInput,
  ListHobbyInput,
  UpdateHobbyInput,
} from './hobby.inputs';

@Injectable()
export class HobbyService {
  constructor(
    @InjectModel(Hobby.name) private hobbyModel: Model<HobbyDocument>,
  ) {}

  create(payload: CreateHobbyInput) {
    const createdHobby = new this.hobbyModel(payload);
    return createdHobby.save();
  }

  getById(_id: MongooseSchema.Types.ObjectId) {
    return this.hobbyModel.findById(_id).exec();
  }

  list(filters: ListHobbyInput) {
    return this.hobbyModel.find({ ...filters }).exec();
  }

  update(payload: UpdateHobbyInput) {
    return this.hobbyModel
      .findByIdAndUpdate(payload._id, payload, { new: true })
      .exec();
  }

  delete(_id: MongooseSchema.Types.ObjectId) {
    return this.hobbyModel.findByIdAndDelete(_id).exec();
  }
}
Enter fullscreen mode Exit fullscreen mode
// hobby.inputs.ts

export class CreateHobbyInput {
  name: string;
}

export class ListHobbyInput {
  _id?: MongooseSchema.Types.ObjectId;
  name?: string;
}

export class UpdateHobbyInput {
  _id: MongooseSchema.Types.ObjectId;
  name?: string;
}
Enter fullscreen mode Exit fullscreen mode

hobbies注意,类的属性Hobby是一个对象引用数组Hobby。因此,我们来做一些调整:

// person.model.ts

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Schema as MongooseSchema } from 'mongoose';

import { Hobby } from '../hobby/hobby.model';

@Schema()
export class Person {
  _id: MongooseSchema.Types.ObjectId;

  @Prop()
  name: string;

  @Prop({ type: [MongooseSchema.Types.ObjectId], ref: Hobby.name })
  hobbies: MongooseSchema.Types.ObjectId[];
}

export type PersonDocument = Person & Document;

export const PersonSchema = SchemaFactory.createForClass(Person);
Enter fullscreen mode Exit fullscreen mode
// person.inputs.ts

import { Hobby } from '../hobby/hobby.model';
import { Schema as MongooseSchema } from 'mongoose';

export class CreatePersonInput {
  name: string;
  hobbies: MongooseSchema.Types.ObjectId[];
}

export class ListPersonInput {
  _id?: MongooseSchema.Types.ObjectId;
  name?: string;
  hobbies?: MongooseSchema.Types.ObjectId[];
}

export class UpdatePersonInput {
  _id: MongooseSchema.Types.ObjectId;
  name?: string;
  hobbies?: MongooseSchema.Types.ObjectId[];
}
Enter fullscreen mode Exit fullscreen mode

GraphQL

我们快完成了,我们只需要实现 graphQL 层。

依赖项安装

$ npm i @nestjs/graphql graphql-tools graphql apollo-server-express
Enter fullscreen mode Exit fullscreen mode

模式生成

至于 mongoose,我们将使用decoratorsfrom@nestjs/graphql来注释我们的 typescript 类,以指示如何生成 graphQL 模式:

// person.model.ts

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Field, ObjectType } from '@nestjs/graphql';
import { Document, Schema as MongooseSchema } from 'mongoose';

import { Hobby } from '../hobby/hobby.model';

@ObjectType()
@Schema()
export class Person {
  @Field(() => String)
  _id: MongooseSchema.Types.ObjectId;

  @Field(() => String)
  @Prop()
  name: string;

  @Field(() => [String])
  @Prop({ type: [MongooseSchema.Types.ObjectId], ref: Hobby.name })
  hobbies: MongooseSchema.Types.ObjectId[];
}

export type PersonDocument = Person & Document;

export const PersonSchema = SchemaFactory.createForClass(Person);
Enter fullscreen mode Exit fullscreen mode
// hobby.model.ts

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Field, ObjectType } from '@nestjs/graphql';
import { Document, Schema as MongooseSchema } from 'mongoose';

@ObjectType()
@Schema()
export class Hobby {
  @Field(() => String)
  _id: MongooseSchema.Types.ObjectId;

  @Field(() => String)
  @Prop()
  name: string;
}

export type HobbyDocument = Hobby & Document;

export const HobbySchema = SchemaFactory.createForClass(Hobby);
Enter fullscreen mode Exit fullscreen mode

有关@nestjs/graphql装饰器的更多信息ObjectTypeField官方文档

解析器

为了定义我们的 graphQL querymutation我们resolvers将创建一个NestJS resolver。我们可以使用 NestJs CLI 来做到这一点:

$ cd src\app\person\
$ nest generate resolver person --flat
$ cd ..\hobby\
$ nest generate resolver hobby --flat
Enter fullscreen mode Exit fullscreen mode

然后,更新生成的文件:

// person.resolver.ts

import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Schema as MongooseSchema } from 'mongoose';

import { Person } from './person.model';
import { PersonService } from './person.service';
import {
  CreatePersonInput,
  ListPersonInput,
  UpdatePersonInput,
} from './person.inputs';

@Resolver(() => Person)
export class PersonResolver {
  constructor(private personService: PersonService) {}

  @Query(() => Person)
  async person(
    @Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
  ) {
    return this.personService.getById(_id);
  }

  @Query(() => [Person])
  async persons(
    @Args('filters', { nullable: true }) filters?: ListPersonInput,
  ) {
    return this.personService.list(filters);
  }

  @Mutation(() => Person)
  async createPerson(@Args('payload') payload: CreatePersonInput) {
    return this.personService.create(payload);
  }

  @Mutation(() => Person)
  async updatePerson(@Args('payload') payload: UpdatePersonInput) {
    return this.personService.update(payload);
  }

  @Mutation(() => Person)
  async deletePerson(
    @Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
  ) {
    return this.personService.delete(_id);
  }
}
Enter fullscreen mode Exit fullscreen mode
// hobby.resolver.ts

import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Schema as MongooseSchema } from 'mongoose';

import { Hobby } from './hobby.model';
import { HobbyService } from './hobby.service';
import {
  CreateHobbyInput,
  ListHobbyInput,
  UpdateHobbyInput,
} from './hobby.inputs';

@Resolver(() => Hobby)
export class HobbyResolver {
  constructor(private hobbyService: HobbyService) {}

  @Query(() => Hobby)
  async hobby(
    @Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
  ) {
    return this.hobbyService.getById(_id);
  }

  @Query(() => [Hobby])
  async hobbies(@Args('filters', { nullable: true }) filters?: ListHobbyInput) {
    return this.hobbyService.list(filters);
  }

  @Mutation(() => Hobby)
  async createHobby(@Args('payload') payload: CreateHobbyInput) {
    return this.hobbyService.create(payload);
  }

  @Mutation(() => Hobby)
  async updateHobby(@Args('payload') payload: UpdateHobbyInput) {
    return this.hobbyService.update(payload);
  }

  @Mutation(() => Hobby)
  async deleteHobby(
    @Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
  ) {
    return this.hobbyService.delete(_id);
  }
}
Enter fullscreen mode Exit fullscreen mode

有关@nestjs/graphql装饰器MutationResolver和的更多信息Query官方文档

让我们向输入类添加一些装饰器,以便 GraphQl 识别它们:

// person.inputs.ts

import { Field, InputType } from '@nestjs/graphql';
import { Schema as MongooseSchema } from 'mongoose';

import { Hobby } from '../hobby/hobby.model';

@InputType()
export class CreatePersonInput {
  @Field(() => String)
  name: string;

  @Field(() => [String])
  hobbies: MongooseSchema.Types.ObjectId[];
}

@InputType()
export class ListPersonInput {
  @Field(() => String, { nullable: true })
  _id?: MongooseSchema.Types.ObjectId;

  @Field(() => String, { nullable: true })
  name?: string;

  @Field(() => [String], { nullable: true })
  hobbies?: MongooseSchema.Types.ObjectId[];
}

@InputType()
export class UpdatePersonInput {
  @Field(() => String)
  _id: MongooseSchema.Types.ObjectId;

  @Field(() => String, { nullable: true })
  name?: string;

  @Field(() => [String], { nullable: true })
  hobbies?: MongooseSchema.Types.ObjectId[];
}
Enter fullscreen mode Exit fullscreen mode
// hobby.inputs.ts

import { Schema as MongooseSchema } from 'mongoose';
import { Field, InputType } from '@nestjs/graphql';

@InputType()
export class CreateHobbyInput {
  @Field(() => String)
  name: string;
}

@InputType()
export class ListHobbyInput {
  @Field(() => String, { nullable: true })
  _id?: MongooseSchema.Types.ObjectId;

  @Field(() => String, { nullable: true })
  name?: string;
}

@InputType()
export class UpdateHobbyInput {
  @Field(() => String)
  _id: MongooseSchema.Types.ObjectId;

  @Field(() => String, { nullable: true })
  name?: string;
}
Enter fullscreen mode Exit fullscreen mode

导入 GraphQLModule

最后,GraphQLModule导入AppModule

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { GraphQLModule } from '@nestjs/graphql';
import { join } from 'path';

import { PersonModule } from './person/person.module';
import { HobbyModule } from './hobby/hobby.module';

import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://localhost:27017/three-in-one-db'),
    GraphQLModule.forRoot({
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
      sortSchema: true,
      playground: true,
      debug: false,
    }),
    PersonModule,
    HobbyModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode
  • autoSchemaFile属性值是自动生成的模式的创建路径
  • sortSchema生成的模式中的类型将按照它们在包含的模块中定义的顺序排列。要按字典顺序对模式进行排序,请将此属性更改为true
  • playground激活graqh-playground
  • debug打开/关闭调试模式

GraphQL 游乐场

Playground 是一个图形化、交互式的浏览器内置 GraphQL IDE,默认访问的 URL 与 GraphQL 服务器相同。要访问 Playground,你需要配置并运行一个基本的 GraphQL 服务器。——
NestJs文档

我们已经在上一步中激活了 playground,一旦服务器启动(yarn start),我们可以通过以下 URL 访问它:http://localhost:3000/graphql

替代文本

填充并解析字段

Mongoose 有一个强大的方法称为populate(),它可以让您引用其他集合中的文档。

填充是指自动将文档中指定的路径替换为其他集合中的文档的过程。我们可以填充单个文档、多个文档、一个普通对象、多个普通对象或查询返回的所有对象。让我们看一些示例。——
Mongoose文档

我们可以使用 Mongoosepopulate来解析hobbies以下字段Person

// person.resolver.ts

import {
  Args,
  Mutation,
  Parent,
  Query,
  ResolveField,
  Resolver,
} from '@nestjs/graphql';
import { Schema as MongooseSchema } from 'mongoose';

import { Person, PersonDocument } from './person.model';
import { PersonService } from './person.service';
import {
  CreatePersonInput,
  ListPersonInput,
  UpdatePersonInput,
} from './person.inputs';
import { Hobby } from '../hobby/hobby.model';

@Resolver(() => Person)
export class PersonResolver {
  constructor(private personService: PersonService) {}

  @Query(() => Person)
  async person(
    @Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
  ) {
    return this.personService.getById(_id);
  }

  @Query(() => [Person])
  async persons(
    @Args('filters', { nullable: true }) filters?: ListPersonInput,
  ) {
    return this.personService.list(filters);
  }

  @Mutation(() => Person)
  async createPerson(@Args('payload') payload: CreatePersonInput) {
    return this.personService.create(payload);
  }

  @Mutation(() => Person)
  async updatePerson(@Args('payload') payload: UpdatePersonInput) {
    return this.personService.update(payload);
  }

  @Mutation(() => Person)
  async deletePerson(
    @Args('_id', { type: () => String }) _id: MongooseSchema.Types.ObjectId,
  ) {
    return this.personService.delete(_id);
  }

  @ResolveField()
  async hobbies(
    @Parent() person: PersonDocument,
    @Args('populate') populate: boolean,
  ) {
    if (populate)
      await person
        .populate({ path: 'hobbies', model: Hobby.name })
        .execPopulate();

    return person.hobbies;
  }
}
Enter fullscreen mode Exit fullscreen mode
// person.model.ts

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Field, ObjectType } from '@nestjs/graphql';
import { Document, Schema as MongooseSchema } from 'mongoose';

import { Hobby } from '../hobby/hobby.model';

@ObjectType()
@Schema()
export class Person {
  @Field(() => String)
  _id: MongooseSchema.Types.ObjectId;

  @Field(() => String)
  @Prop()
  name: string;

  @Field(() => [Hobby])
  @Prop({ type: [MongooseSchema.Types.ObjectId], ref: Hobby.name })
  hobbies: MongooseSchema.Types.ObjectId[] | Hobby[];
}

export type PersonDocument = Person & Document;

export const PersonSchema = SchemaFactory.createForClass(Person);
Enter fullscreen mode Exit fullscreen mode

这使我们能够Person以这种方式发出请求:

替代文本


结论

作为开发者,我们会将不同的技术模块组合起来,为我们的项目构建一个可行的生态系统🌍。在 JavaScript 生态系统中,其优势(或劣势)在于模块的丰富性和组合的可能性。我希望能够继续撰写这篇博文的续篇,解释如何在 Monorepo 开发环境中集成 GraphQL API,从而在多个应用程序和库之间共享此数据模型。

鏂囩珷鏉ユ簮锛�https://dev.to/lotfi/third-in-one-code-first-nestjs-graphql-mongoose-30ie
PREV
技术领导者的 11 项首要职责和 10 个常见错误 关于领导力 缺乏领导力是痛苦的 领导者的角色 团队的角色 搭建乐高塔 技术领导力 技术领导者的 11 项首要职责 10 个常见的技术领导力错误
NEXT
如何使用 JavaScript 创建 NFT