本文通过搭建一个简单的商品订单系统,来介绍如何基于 GraphQL、NestJS 构建一个 BFF 服务。

TypeScript 这几年迅猛发展,不仅在前端开发中使用越来越广泛,在 NodeJS 端也逐步铺开。本文选择基于 TypeScript 开发的服务端框架 NestJSopen in new window 和已经被社区广泛使用的 Apolloopen in new window 来实现 BFF 服务。

项目初始化

我们直接使用 NestJS 的 CLI(命令行)工具来初始化项目,假设我们的应用名称是 bff-graphql-server

yarn global add @nestjs/cli
nest new bff-graphql-server
1
2

注意,在写这篇文章的时候,NestJS 官方刚刚将 NestJS 的版本升级到 v8,一些周边的依赖包存在版本不匹配问题,因此建议使用 v7 版本,或者直接 fork demo 项目 repoopen in new window

初始化完成后,我们添加 GraphQL 依赖。

yarn add @nestjs/graphql graphql-tools graphql apollo-server-express
1

至此,我们已经完成了项目初始化。

添加应用代码

在开始写业务代码之前,我们先规划一下目录结构。

默认初始化出来的项目目录如下:

-- src
  -- main.ts // 系统启动入口
  -- app.module.ts // 应用主模块
  -- app.controller.ts // 控制器
  -- app.service.ts // 服务,用于获取数据
1
2
3
4
5

我们新增加三个目录,分别是 modelsservicesresolvers

-- src
  -- modules  // 存放模型定义,最终生成 GraphQL 的 Schema
  -- resolvers // 存放 Resolver,实现数据的聚合裁减
  -- services // 对接后端服务接口,用于获取数据
1
2
3
4

在 BFF 中任何一个领域模型,都需要一份模型定义,一个获取数据的 Service 和一个用于数据聚合和裁减的 Resolver

在这个例子中,我们需要定义两个模型,商品模型和订单模型。

初始化 GraphQLModule

在 NestJS 中,我们通过 module 来管理代码,因此,我们需要初始化 GraphQLModule

修改默认的 src/app.module.ts 代码如下:

import { HttpModule, Module } from '@nestjs/common';
import { GqlModuleOptions, GraphQLModule } from '@nestjs/graphql';
import { AppController } from './app.controller';
import resolvers from './resolvers';
import services from './services';

@Module({
  imports: [
    HttpModule,
    GraphQLModule.forRoot({
      debug: true, // 生产环境中需要关闭
      introspection: true, // 开启内省模式,生产环境中需要关闭
      path: '/graphql', // 定义 GraphQL 的请求路径
      autoSchemaFile: true, // 自动生成 schema 文件
      playground: { // UI 界面
        // 生产环境中需要关闭
        settings: {
          'request.credentials': 'same-origin',
        },
      },
    } as GqlModuleOptions),
  ],
  controllers: [AppController],
  providers: [...services, ...resolvers], // 系统中定义的 service 和 resolver
})
export class AppModule {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

商品模型

定义模型

models 目录下新建一个 goods 目录:

-- src
  -- models
    -- goods
1
2
3

定义商品模型,在这个模型中,商品有三个字段:

import { Field, ObjectType } from '@nestjs/graphql';

@ObjectType({ description: '商品信息' })
export class GoodsData {
  @Field(() => String, { nullable: false, description: '商品 ID' })
  goodsId: string;

  @Field(() => String, { nullable: false, description: '商品名称' })
  goodsName: string;

  @Field(() => String, { nullable: true, description: '商品简介' })
  goodsBrief?: string;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

现在我们定义查询商品模型的参数:

import { Field, InputType } from '@nestjs/graphql';

@InputType({ description: '商品详情参数' })
export class GoodsParam {
  @Field(() => String, { nullable: false, description: '商品 ID' })
  goodsId: string;
}
1
2
3
4
5
6
7

定义 Service

services 目录下新建 goods 目录:

-- src
  -- services
    -- goods
1
2
3

创建一个 GoodsService 类:

import { HttpService, Injectable } from '@nestjs/common';
import { GoodsData, GoodsParam } from 'src/models/goods/model';

@Injectable()
export class GoodsService {
  constructor(private readonly http: HttpService) {}

  getGoods(param: GoodsParam): Promise<GoodsData> {
    return this.http
      .get<GoodsData>('/goodsservice/rest/goods/detail', { params: param })
      .toPromise()
      .then((resp) => resp.data);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

我们假定通过 /goodsservice/rest/goods/detail 可以查询商品详情。 HttpService 是 NestJS 提供的基于 Axiosopen in new window 的封装。

定义好 GoodsService 以后,我们将这个类添加到 src/services/index.ts 中导出。

import { GoodsService } from './goods';

export default [GoodsService];
1
2
3

定义 Resolver

resolvers 目录下新建 goods 目录:

-- src
  -- resolvers
    -- goods
1
2
3

创建一个 GoodsResolver 类:

import { Args, Query, Resolver } from '@nestjs/graphql';
import { GoodsData, GoodsParam } from 'src/models/goods/model';
import { GoodsService } from 'src/services/goods/goods';

@Resolver()
export class GoodsResolver {
  constructor(private readonly goodsService: GoodsService) {}

  @Query(() => GoodsData, {
    name: 'goodsData',
  })
  goods(@Args('param') param: GoodsParam): Promise<GoodsData> {
    return this.goodsService.getGoods(param.goodsId);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

定义好 GoodsResolver 以后,我们将这个类添加到 src/resolvers/index.ts 中导出。

import { GoodsResolver } from './goods';

export default [GoodsResolver];
1
2
3

完成以后,启动进程,我们就可以在 playground 里面看到定义的商品模型了,如下图。

GraphQL Schema 图形展示

我们在 playground 中做一些查询看看效果。

GraphQL playground

订单模型

与商品模型类似,我们需要做一些定义。

定义模型

models 目录下新建一个 orders 目录:

-- src
  -- models
    -- orders
1
2
3

定义订单模型和查询参数,如下:

import { Field, Float, InputType, ObjectType } from '@nestjs/graphql';

@ObjectType({ description: '订单数据' })
export class OrderData {
  @Field(() => String, { nullable: false, description: '订单号' })
  orderNo: string;

  @Field(() => String, { nullable: false, description: '商品 ID' })
  goodsId: string;

  @Field(() => Float, { nullable: false, description: '金额' })
  pay: number;

  @Field(() => String, { nullable: true, description: '备注信息' })
  comment?: string;
}

@InputType({ description: '订单详情参数' })
export class OrderParam {
  @Field(() => String, { nullable: false, description: '订单号' })
  orderNo: string;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

需要注意的是,OrderData 中有一个 goodsId 字段与商品建立关系,我们后面在 Resolver 中需要借助于这个字段来查询商品信息。

定义 Service

services 目录下新建 orders 目录:

-- src
  -- services
    -- orders
1
2
3

创建一个 OrderService 类:

import { HttpService, Injectable } from '@nestjs/common';
import { OrderData, OrderParam } from 'src/models/orders/model';

@Injectable()
export class OrderService {
  constructor(private readonly http: HttpService) {}

  getOrder(param: OrderParam): Promise<OrderData> {
    return this.http
      .get<OrderData>('/goodsservice/rest/order/detail', { params: param })
      .toPromise()
      .then((resp) => resp.data);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

别忘了在 src/services/index.ts 添加 OrderService 的导出。

定义 Resolver

resolvers 目录下新建 orders 目录:

-- src
  -- resolvers
    -- orders
1
2
3

创建一个 OrderResolver 类:

import { Args, Query, Resolver } from '@nestjs/graphql';
import { OrderData OrderParam } from 'src/models/orders/model';
import { OrderService } from 'src/services/orders/order';

@Resolver()
export class OrderResolver {
  constructor(private readonly orderService: OrderService) {}

  @Query(() => OrderData, {
    name: 'orderData',
  })
  order(@Args('param') param: OrderParam): Promise<OrderData> {
    return this.orderService.getOrder(param);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

注意,别忘了在 src/resolvers/index.ts 中添加 OrderResolver 的导出。

定义好 OrderResolver 以后,我们就可以查询订单信息。

这时候,如果我们直接查询订单上的商品信息是查不到,因为还没建立相关的 Resolver。

我们现在添加一个 OrderPropertyResolver 类:

import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { GoodsData } from 'src/models/goods/model';
import { OrderData } from 'src/models/orders/model';
import { GoodsService } from 'src/services/goods/goods';

@Resolver(() => OrderData)
export class OrderPropertyResolver {
  constructor(private readonly goodsService: GoodsService) {}

  @ResolveField(() => GoodsData, {
    name: 'goodsData',
    nullable: true,
  })
  goodsData(@Parent() orderData: OrderData): Promise<GoodsData> {
    return this.goodsService.getGoodsById(orderData.goodsId);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

注意这里 @Resolver(() => OrderData) 的写法,它告诉 GraphQL,当需要解析 OrderData 上的字段信息时,使用这个 Resolver 类。

我们通过 @Parent() 装饰器可以拿到 OrderData 上的数据,在这个例子中就是 goodsId

这里要额外说一点,通常来说,单个订单直接通过商品详情接口查商品数据没有问题,但是如果是订单列表要查商品数据,依然通过商品详情接口查的话就会产生 N + 1 问题了。

在前面的文章中我们提到过,可以借助于 DataLoaderopen in new window 来解决 N + 1 问题。

我们对 GoodsService 做一些改造,引入 DataLoader。

import DataLoader from 'dataloader';

@Injectable()
export class GoodsService {
  // 其他代码

  private goodsLoader = new DataLoader<string, GoodsData>(
    async (goodsIdList: readonly string[]) => {
      const goodsList = await this.batchGetGoodsByGoodsId(
        goodsIdList as string[],
      );
      return goodsIdList.map((goodsId) =>
        goodsList.find((goodsData) => goodsData.goodsId === goodsId),
      );
    },
  );

  private batchGetGoodsByGoodsId(goodsIdList: string[]): Promise<GoodsData[]> {
    return this.http
      .get<GoodsData[]>('/goodsservice/rest/goods/list-by-id', {
        params: goodsIdList,
      })
      .toPromise()
      .then((resp) => {
        return resp.data;
      });
  }

  getGoodsById(goodsId: string): Promise<GoodsData> {
    return this.goodsLoader.load(goodsId);
  }

  getGoodsByIdList(goodsIdList: string[]): Promise<(GoodsData | Error)[]> {
    return this.goodsLoader.loadMany(goodsIdList);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

正如代码中所示,DataLoader 依赖后端提供一个批量接口,即通过 goodsId 批量查询 GoodsData 信息。

完成以后,我们就可以愉快的在查询订单的时候也查询商品信息了。

查询订单信息

小结

我们通过搭建一个简单的订单、商品系统展示了如果开发一个基于 GraphQL 的 BFF 服务。

结合 NestJS 和 Apollo,我们可以方便的通过 TypeScript 来开发 GraphQL 服务,极大的提升开发效率。

借助于 GraphQL 的 playground,我们可以方便的对系统进行查询,不依赖前端代码实现。

关注微信公众号,获取最新推送~