angular 模块构建_使用Express,Angular和GraphQL构建一个简单的Web应用程序

angular 模块构建

本文最初发布在Okta开发人员博客上 。 感谢您支持使SitePoint成为可能的合作伙伴。

在过去的10年左右的时间里,用于Web服务的REST API的概念已成为大多数Web开发人员的头等大事。 最近出现了一个新概念GraphQL。 GraphQL是一种由Facebook发明并于2015年向公众发布的查询语言。在过去三年中,它引起了极大的轰动。 有人将其视为创建Web API的革命性新方法。 传统REST和GraphQL之间的主要区别是查询发送到服务器的方式。 在REST API中,每种资源类型都有一个不同的终结点,并且对请求的响应由服务器确定。 使用GraphQL,您通常只有一个端点,客户端可以明确声明应返回哪些数据。 GraphQL中的单个请求可以包含对基础模型的多个查询。

在本教程中,我将向您展示如何开发一个简单的GraphQL Web应用程序。 该服务器将使用Node and Express运行,而客户端将基于Angular7。您将看到准备服务器以响应不同的查询是多么容易。 与实现REST风格的API相比,这省去了许多工作。 为了提供一个示例,我将创建一个服务,用户可以在其中浏览ATP网球运动员和排名。

使用GraphQL构建Express Server

我将从实现服务器开始。 我将假设您的系统上已安装Node ,并且npm命令可用。 我还将使用SQLite来存储数据。 为了创建数据库表并导入数据,我将使用sqlite3命令行工具。 如果尚未安装sqlite3 ,请转到SQLite下载页面并安装包含命令行shell的软件包。

首先,创建一个包含服务器代码的目录。 我已经简单地称为我的server/ 在目录内运行

npm init -y

接下来,您将必须使用基本服务器所需的所有软件包来初始化项目。

npm install --save [email protected] [email protected] [email protected] [email protected] [email protected]

将数据导入到Express服务器

接下来,让我们创建数据库表并将一些数据导入其中。 我将利用Jeff Sackmann提供的免费ATP网球排名。 在系统上的某个目录中,克隆GitHub存储库。

git clone https://github.com/JeffSackmann/tennis_atp.git

在本教程中,我将只使用该存储库中的两个文件atp_players.csvatp_rankings_current.csv server/目录中,启动SQLite。

sqlite3 tennis.db

这将创建一个文件tennis.db ,其中将包含数据并为您提供命令行提示符,您可以在其中键入SQL命令。 让我们创建数据库表。 将以下内容粘贴并运行在SQLite3 shell中。

CREATE TABLE players(
  "id" INTEGER,
  "first_name" TEXT,
  "last_name" TEXT,
  "hand" TEXT,
  "birthday" INTEGER,
  "country" TEXT
);

CREATE TABLE rankings(
  "date" INTEGER,
  "rank" INTEGER,
  "player" INTEGER,
  "points" INTEGER
);

SQLite允许您快速将CSV数据导入表中。 只需在SQLite3 shell中运行以下命令。

.mode csv
.import {PATH_TO_TENNIS_DATA}/atp_players.csv players
.import {PATH_TO_TENNIS_DATA}/atp_rankings_current.csv rankings

在以上内容中,将{PATH_TO_TENNIS_DATA}替换为您下载网球数据存储库的路径。 现在,您已经创建了一个数据库,其中包含有史以来所有ATP排名的网球运动员,以及当年所有活跃运动员的排名。 您准备离开SQLite3。

.quit

实施Express Server

现在让我们实现服务器。 打开一个新文件index.js ,它是服务器应用程序的主要入口点。 从Express和CORS基础开始。

const express = require('express');
const cors = require('cors');

const app = express().use(cors());

现在导入SQLite并在tennis.db打开网球数据库。

const sqlite3 = require('sqlite3');
const db = new sqlite3.Database('tennis.db');

这将创建一个变量db ,您可以在该变量上发出SQL查询并获取结果。

现在,您准备好进入GraphQL的魔力了。 将以下代码添加到index.js文件中。

const graphqlHTTP = require('express-graphql');
const { buildSchema } = require('graphql');

const schema = buildSchema(`
  type Query {
    players(offset:Int = 0, limit:Int = 10): [Player]
    player(id:ID!): Player
    rankings(rank:Int!): [Ranking]
  }

  type Player {
    id: ID
    first_name: String
    last_name: String
    hand: String
    birthday: Int
    country: String
  }

  type Ranking {
    date: Int
    rank: Int
    player: Player
    points: Int
  }
`);

前两行导入graphqlHTTPbuildSchema 函数graphqlHTTP插入Express并能够理解和响应GraphQL请求。 buildSchema用于从字符串创建GraphQL模式。 让我们更详细地了解模式定义。

PlayerRanking这两种类型反映了数据库表的内容。 这些将用作GraphQL查询的返回类型。 如果仔细观察,可以看到“ Ranking的定义包含具有“ Player类型的“ Player player字段。 此时,数据库只有一个INTEGER ,它引用players表中的一行。 GraphQL数据结构应将此整数替换为其所引用的播放器。

type Query定义允许客户端进行的查询。 在此示例中,存在三个查询。 players返回数组Player结构。 该列表可以通过offsetlimit 这将允许在玩家表中进行分页。 player查询通过ID返回单个玩家。 rankings查询将返回给定玩家排名的Ranking对象数组。

为了使您的生活更轻松,请创建一个实用程序函数,该函数发出SQL查询并返回一个Promise ,该Promise在查询返回时解决。 这很有用,因为sqlite3接口基于回调,但是GraphQL与Promises更好地配合。 index.js添加以下功能。

function query(sql, single) {
  return new Promise((resolve, reject) => {
    var callback = (err, result) => {
      if (err) {
        return reject(err);
      }
      resolve(result);
    };

    if (single) db.get(sql, callback);
    else db.all(sql, callback);
  });
}

免费学习PHP!

全面介绍PHP和MySQL,从而实现服务器端编程的飞跃。

原价$ 11.95 您的完全免费

免费获得这本书

现在该实现支持GraphQL查询的数据库查询了。 GraphQL使用一种称为rootValue东西来定义与GraphQL查询相对应的函数。

const root = {
  players: args => {
    return query(
      `SELECT * FROM players LIMIT ${args.offset}, ${args.limit}`,
      false
    );
  },
  player: args => {
    return query(`SELECT * FROM players WHERE id='${args.id}'`, true);
  },
  rankings: args => {
    return query(
      `SELECT r.date, r.rank, r.points,
              p.id, p.first_name, p.last_name, p.hand, p.birthday, p.country
      FROM players AS p
      LEFT JOIN rankings AS r
      ON p.id=r.player
      WHERE r.rank=${args.rank}`,
      false
    ).then(rows =>
      rows.map(result => {
        return {
          date: result.date,
          points: result.points,
          rank: result.rank,
          player: {
            id: result.id,
            first_name: result.first_name,
            last_name: result.last_name,
            hand: result.hand,
            birthday: result.birthday,
            country: result.country
          }
        };
      })
    );
  }
};

前两个查询非常简单。 它们由简单的SELECT语句组成。 结果直接返回。 rankings查询稍微复杂一点,因为需要LEFT JOIN语句来组合两个数据库表。 然后,将结果转换为GraphQL查询的正确数据结构。 在所有这些查询中,请注意args如何包含从客户端传入的参数。 您无需担心检查缺失值,分配默认值或检查正确的类型。 GraphQL服务器为您完成了所有这些工作。

剩下要做的就是创建一条路由,并将graphqlHTTP函数链接到其中。

app.use(
  '/graphql',
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true
  })
);

app.listen(4201, err => {
  if (err) {
    return console.log(err);
  }
  return console.log('My Express App listening on port 4201');
});

graphiql为您提供了一个不错的用户界面,您可以在该界面上测试对服务器的查询。

要启动服务器运行:

node index.js

然后打开浏览器并导航到http://localhost:4201/graphql 您将看到一个针对GraphQL查询的交互式测试平台。

添加您的Angular 7客户端

什么是没有客户端的Web应用程序? 在本节中,我将指导您使用Angular 7实现单页应用程序。首先,创建一个新的Angular应用程序。 如果尚未安装,请在系统上安装最新版本的angular命令行工具。

npm install -g @angular/[email protected]

您可能必须使用sudo运行此命令,具体取决于您的操作系统。 现在,您可以创建一个新的角度应用程序。 在新目录中运行:

ng new AngularGraphQLClient

这将创建一个新目录,并将Angular应用程序的所有必需软件包安装到其中。 系统将提示您两个问题。 回答 ,将路由包括在应用程序中。 我将在本教程中使用的样式表将是简单CSS。

该应用程序将包含与主app模块关联的三个组件。 您可以通过导航到刚刚创建的目录并运行以下三个命令来生成它们。

ng generate component Home
ng generate component Players
ng generate component Ranking

这将在src/app创建三个目录,并为每个组件添加组件.ts代码文件, .html模板和.css样式表。 为了在Angular中使用GraphQL,我将使用Apollo库。 用角度设置Apollo是一个简单的命令。

ng add apollo-angular

此命令将安装许多Node模块。 它还将在/src/app/文件夹中的文件graphql.module.ts中创建一个Angular模块,并将其导入到主app模块中。 在此文件内,您将看到以下行

const uri = ''; // <-- add the URL of the GraphQL server here

更改为

const uri = 'http://localhost:4201/graphql';

这指定可以在其中找到GraphQL服务的URI。

注意:如果要在安装Apollo Angular之后生成任何组件,则需要指定该组件所属的模块。 因此,生成上面的Home组件将更改为

ng generate component Home --module app

我将使用表单模块,以便将值绑定到HTML中的输入元素。 打开src/app/app.module.ts并添加

import { FormsModule } from '@angular/forms';

到文件顶部。 然后在@NgModule声明中将FormsModule添加到imports数组。

在Angular中创建布局和布线

现在打开src/index.html 该文件包含Angular应用程序将在其中运行HTML容器。 您将需要一些外部CSS和JavaScript资源来完善应用程序的设计。 标记内添加以下行。 这将包括一些最小的Material Design样式。



接下来,打开src/app.component.html并将内容替换为以下内容。

home Angular with GraphQL

这将创建一个具有顶部栏和一些链接的基本布局,这些链接会将不同的组件加载到router-outlet 为了加载使路由对应用程序可用,您应该修改app-routing.module.ts 在顶部,您将看到routes数组的声明。

const routes: Routes = [];

用以下内容替换此行。

import { PlayersComponent } from './players/players.component';
import { HomeComponent } from './home/home.component';
import { RankingComponent } from './ranking/ranking.component';

const routes: Routes = [
  {
    path: '',
    component: HomeComponent
  },
  {
    path: 'players',
    component: PlayersComponent
  },
  {
    path: 'ranking',
    component: RankingComponent
  }
];

现在,当选择了特定的路由时,路由器便知道将哪些组件放入插座。 至此,您的应用程序已经显示了三个页面,顶部栏中的链接会将它们加载到应用程序的内容区域。

最后,让我们给页面添加一些样式。 app.component.css粘贴以下内容。

.content {
  padding: 1rem;
  display: flex;
  justify-content: center;
}

在Angular中添加组件

您已准备好实现这些组件。 让我们从允许用户翻阅数据库中所有网球运动员的组件开始。 将以下内容复制到文件src/app/players/players.component.ts 接下来,我将引导您了解该文件各部分的含义。

import { Component, OnInit } from '@angular/core';
import { Apollo, QueryRef } from 'apollo-angular';
import gql from 'graphql-tag';

const PLAYERS_QUERY = gql`
  query players($offset: Int) {
    players(offset: $offset, limit: 10) {
      id
      first_name
      last_name
      hand
      birthday
      country
    }
  }
`;

@Component({
  selector: 'app-players',
  templateUrl: './players.component.html',
  styleUrls: ['./players.component.css']
})
export class PlayersComponent implements OnInit {
  page = 1;
  players: any[] = [];

  private query: QueryRef;

  constructor(private apollo: Apollo) {}

  ngOnInit() {
    this.query = this.apollo.watchQuery({
      query: PLAYERS_QUERY,
      variables: { offset: 10 * this.page }
    });

    this.query.valueChanges.subscribe(result => {
      this.players = result.data && result.data.players;
    });
  }

  update() {
    this.query.refetch({ offset: 10 * this.page });
  }

  nextPage() {
    this.page++;
    this.update();
  }

  prevPage() {
    if (this.page > 0) this.page--;
    this.update();
  }
}

该文件的前三行包含驱动组件所需的导入。

import { Component, OnInit } from '@angular/core';
import { Apollo, QueryRef } from 'apollo-angular';
import gql from 'graphql-tag';

除了核心的Angular导入之外,这还使apollo-angular可以使用ApolloQueryRef ,而graphql-tag gql 这些to中的后者立即用于创建GraphQL查询。

const PLAYERS_QUERY = gql`
  query players($offset: Int) {
    players(offset: $offset, limit: 10) {
      id
      first_name
      last_name
      hand
      birthday
      country
    }
  }
`;

gql标记接受模板字符串并将其转换为查询对象。 此处定义的查询将要求服务器返回玩家列表,其中填充了所有玩家字段。 limit参数将导致服务器最多返回10条记录。 可以将offset参数指定为查询的参数。 这将允许通过播放器进行分页。

@Component({
  selector: 'app-players',
  templateUrl: './players.component.html',
  styleUrls: ['./players.component.css']
})
export class PlayersComponent implements OnInit {
  page = 0;
  players: any[] = [];

  private query: QueryRef;

  constructor(private apollo: Apollo) {}
}

PlayersComponent的属性指定组件的状态。 属性page将当前page存储在播放器列表中。 players将包含将在表格中显示的玩家数组。 还有一个用于存储查询的query变量。 每当用户导航到另一个页面时,就需要能够重新获取数据。 构造函数将注入apollo属性,以便您可以访问GraphQL接口。

ngOnInit() {
  this.query = this.apollo
    .watchQuery({
      query: PLAYERS_QUERY,
      variables: {offset : 10*this.page}
    });

    this.query.valueChanges.subscribe(result => {
      this.players = result.data && result.data.players;
    });
  }

在组件生命周期的初始化阶段,将调用ngOnInit方法。 这是玩家组件将启动数据加载的地方。 这是通过this.apollo.watchQuery实现的。 通过将PLAYERS_QUERY以及offset参数的值一起传递。 现在,您可以使用valueChanges.subscribe订阅任何数据更改。 此方法采用回调,该回调将使用从服务器获得的数据来设置players数组。

update() {
  this.query.refetch({offset : 10*this.page});
}

nextPage() {
  this.page++;
  this.update();
}

prevPage() {
  if (this.page>0) this.page--;
  this.update();
}

为了解决问题, nextPageprevPage将增加或减少page属性。 通过使用新参数对query调用refetch ,可以发出服务器请求。 收到数据后,将自动调用订阅回调。

该组件随附HTML模板存储在players.component.html 将以下内容粘贴到其中。

First Name Last Name Hand Birthday Country
{{player.first_name}} {{player.last_name}} {{player.hand}} {{player.birthday}} {{player.country}}
Page {{page+1}}

这将在表中显示玩家列表。 在表格下方,我添加了分页链接。

排名组件几乎遵循相同的模式。 src/app/ranking.component.ts看起来像这样。

import { Component, OnInit } from '@angular/core';
import { Apollo, QueryRef } from 'apollo-angular';
import gql from 'graphql-tag';

const RANKINGS_QUERY = gql`
  query rankings($rank: Int!) {
    rankings(rank: $rank) {
      date
      rank
      points
      player {
        first_name
        last_name
      }
    }
  }
`;

@Component({
  selector: 'app-ranking',
  templateUrl: './ranking.component.html',
  styleUrls: ['./ranking.component.css']
})
export class RankingComponent implements OnInit {
  rank: number = 1;
  rankings: any[];
  private query: QueryRef;

  constructor(private apollo: Apollo) {}

  ngOnInit() {
    this.query = this.apollo.watchQuery({
      query: RANKINGS_QUERY,
      variables: { rank: Math.round(this.rank) }
    });

    this.query.valueChanges.subscribe(result => {
      this.rankings = result.data && result.data.rankings;
    });
  }

  update() {
    return this.query.refetch({ rank: Math.round(this.rank) });
  }
}

如您所见,大多数代码与players.component.ts中的代码非常相似。 RANKINGS_QUERY的定义随着时间的推移查询拥有特定排名的玩家。 请注意,查询仅请求播放器的first_namelast_name 这意味着服务器将不会发送回客户端未要求的任何其他播放器数据。

排名组件的模板包含一个文本字段和一个按钮,用户可以在其中输入排名并重新加载页面。 下面是玩家表。 这是ranking.component.html的内容。

Rankings

Rank Date Points First Name Last Name
{{ranking.rank}} {{ranking.date}} {{ranking.points}} {{ranking.player.first_name}} {{ranking.player.last_name}}

要启动客户端,请运行:

ng serve

确保服务器也正在运行,以便客户端可以成功请求数据。

将访问控制添加到Express + Angular GraphQL App

每个Web应用程序最重要的功能之一就是用户身份验证和访问控制。 在本节中,我将指导您完成向Angular应用程序的服务器和客户端部分添加身份验证所需的步骤。 这通常是编写应用程序中最艰巨的部分。 使用Okta可以大大简化此任务,并使每个开发人员都可以使用安全身份验证。 如果尚未执行此操作,请使用Okta创建一个开发人员帐户。 访问https://developer.okta.com/并选择创建免费帐户

填写表格并注册。 注册完成后,您可以看到开发人员仪表板。

从仪表板的顶部菜单中,选择“ 应用程序” ,然后通过单击绿色的“ 添加应用程序”按钮来添加应用程序

您将看到不同类型的应用程序的选择。 您正在注册单页应用程序 在下一页上,您将看到应用程序的设置。 此处,端口号已预先填写为8080。Angular默认使用端口4200。 因此,您必须将端口号更改为4200。

完成后,将为您提供ClientId 您的客户端和服务器应用程序中都将需要此功能。 您还将需要您的Okta开发人员域。 这是您登录Okta开发人员仪表板时在页面顶部看到的URL。

保护您的Angular客户端

为了在Angular客户端上使用Okta身份验证,您将必须安装okta-angular库。 在客户端应用程序的基本目录中,运行以下命令。

npm install @okta/[email protected] [email protected] --save

现在打开src/app/app.module.ts 在文件顶部添加import语句。

import { OktaAuthModule } from '@okta/okta-angular';

现在,将该模块添加到app模块的imports列表中。

OktaAuthModule.initAuth({
  issuer: 'https://{yourOktaDomain}/oauth2/default',
  redirectUri: 'http://localhost:4200/implicit/callback',
  clientId: '{yourClientId}'
});

当您导航到Okta仪表板时,您将需要替换在浏览器中看到的yourOktaDomain开发域。 另外,将yourClientId替换为注册应用程序时获得的客户端ID。 现在,您可以在整个应用程序中使用Okta身份验证了。 接下来,您将实现从应用程序登录和注销。 打开app.component.ts并从OktaAuthService okta-angular导入OktaAuthService 将以下代码粘贴到文件中。

import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { OktaAuthService } from '@okta/okta-angular';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  public title = 'My Angular App';
  public isAuthenticated: boolean;

  constructor(public oktaAuth: OktaAuthService) {
    this.oktaAuth.$authenticationState.subscribe(
      (isAuthenticated: boolean) => (this.isAuthenticated = isAuthenticated)
    );
  }

  async ngOnInit() {
    this.isAuthenticated = await this.oktaAuth.isAuthenticated();
  }

  login() {
    this.oktaAuth.loginRedirect();
  }

  logout() {
    this.oktaAuth.logout('/');
  }
}

OktaAuthService服务是通过构造函数注入的。 然后用于设置isAuthenticated标志。 当登录状态发生更改时, subscribe方法将订阅一个回调函数。 ngOnInit阶段初始化isAuthenticated以反映首次加载应用程序时的登录状态。 loginlogout处理loginlogout的过程。 为了使身份验证有效, okta-angular使用了一种称为implicit/callback的特殊路由。 在文件app-routing.module.ts添加以下导入。

import { OktaCallbackComponent, OktaAuthGuard } from '@okta/okta-angular';

现在,通过将以下内容添加到routes数组,将implicit/callback路由链接到OktaCallbackComponent

{
  path: 'implicit/callback',
  component: OktaCallbackComponent
}

这是登录和注销所需的全部。 但是该应用程序尚未受保护。 对于要访问控制的任何路由,都必须添加一个授权保护。 幸运的是,这很容易。 在每个要保护的路由中,添加canActivate属性。 将以下内容添加到playersranking路线中。

canActivate: [OktaAuthGuard];

这里的所有都是它的。 现在,当用户尝试访问“玩家”视图时,他将被重定向到Okta登录页面。 登录后,用户将被重定向回“产品”视图。

您已经保护了客户端页面,但是在继续保护后端之前,让我们花点时间考虑一下服务器将如何对用户进行身份验证。 Okta使用承载令牌来标识用户。 承载令牌必须随每个请求一起发送到服务器。 为此,客户端必须确保将承载令牌添加到HTTP标头中。 您需要做的就是向graphql.module.ts添加几行代码。 在文件顶部导入以下内容。

import { OktaAuthService } from '@okta/okta-angular';
import { setContext } from 'apollo-link-context';

然后修改createApollo函数以添加承载令牌。

export function createApollo(httpLink: HttpLink, oktaAuth: OktaAuthService) {
  const http = httpLink.create({ uri });

  const auth = setContext((_, { headers }) => {
    return oktaAuth.getAccessToken().then(token => {
      return token ? { headers: { Authorization: `Bearer ${token}` } } : {};
    });
  });

  return {
    link: auth.concat(http),
    cache: new InMemoryCache()
  };
}

保护您的Express GraphQL服务器

通过将明确的中间件功能添加到服务器应用程序来保护服务器的安全。 为此,您将需要一些其他库。 转到服务器目录并运行命令

npm install @okta/[email protected] [email protected] [email protected]

接下来,让我们在服务器的根文件夹中的另一个名为auth.js文件中创建该函数。

const OktaJwtVerifier = require('@okta/jwt-verifier');

const oktaJwtVerifier = new OktaJwtVerifier({
  clientId: '{yourClientId}',
  issuer: 'https://{yourOktaDomain}/oauth2/default'
});

module.exports = async function oktaAuth(req, res, next) {
  try {
    const token = req.token;
    if (!token) {
      return res.status(401).send('Not Authorized');
    }
    const jwt = await oktaJwtVerifier.verifyAccessToken(token);
    req.user = {
      uid: jwt.claims.uid,
      email: jwt.claims.sub
    };
    next();
  } catch (err) {
    return res.status(401).send(err.message);
  }
};

同样,您必须用开发域和客户端ID替换yourOktaDomainyourClientId 此功能的目的很简单。 它检查请求中是否存在令牌字段。 如果存在,则oktaJwtVerifier检查令牌的有效性。 如果一切正常,则调用next()表示成功。 否则,将返回401错误。 现在您要做的就是确保该功能已在应用程序中使用。 将以下require语句添加到index.js文件。

const bodyParser = require('body-parser');
const bearerToken = require('express-bearer-token');
const oktaAuth = require('./auth');

然后通过以下方式修改app的声明。

const app = express()
  .use(cors())
  .use(bodyParser.json())
  .use(bearerToken())
  .use(oktaAuth);

bearerToken中间件将查找承载令牌并将其添加到oktaAuth的请求中以查找它。 通过这种简单的添加,您的服务器将只允许提供有效身份验证的请求。

了解有关Express,Angular和GraphQL的更多信息

在这个简单的教程中,我向您展示了如何使用GraphQL使用Angular创建单页应用程序。 使用Okta服务以最小的努力实现了用户身份验证。

我还没有讨论过如何使用GraphQL添加或修改数据库中的数据。 在GraphQL语言中,这称为突变 要了解有关使用Apollo进行的突变的更多信息,请查看手册页 。

该项目的完整代码可以在https://github.com/oktadeveloper/okta-graphql-angular-example中找到。

如果您有兴趣了解有关Express,Angular,GraphQL或安全用户管理的更多信息,建议您检查以下任何资源:

  • 使用Express和GraphQL构建简单的API服务
  • 使用Spring Boot和GraphQL构建安全的API
  • 构建并了解Express中间件
  • Angular 6:新增功能以及为何进行升级?
  • 使用Angular和Node构建基本的CRUD应用

就像你今天学到的一样? 我们希望您在Twitter上关注我们并订阅我们的YouTube频道 !

翻译自: https://www.sitepoint.com/build-a-simple-web-app-with-express-angular-and-graphql/

angular 模块构建

你可能感兴趣的:(angular 模块构建_使用Express,Angular和GraphQL构建一个简单的Web应用程序)