【2019年2月末版】Apollo + Express + Angular实现GraphQL Pub/Sub 〜 好好地使用Subscription

使用Apollo + Express + Angular 进行GraphQL服务器实现。

据说GraphQL的Subscription功能终于可以使用了?所以我尝试了GraphQL的Pub/Sub实现。
后端的GraphQL服务器是基于Apollo + Express,在前端,Web服务器是Apollo + Angular。
这次的目的只是进行验证实验,所以我会简单地使用单一的Angular应用来构建。

成果物在这里 → 请查看 github.com/nGraphQL。

由于是一体化的,所以Express也是使用TypeScript编写的。我正在努力使用ts-node来运行它。

请您提供参考网址。

只要详细阅读GraphQL官方网站GraphQL.org上关于定义和编写的详细说明,您就能够很好地理解它。(虽然可能会有些困难……)

    Introduction to GraphQL at GraphQL.org

同时,Apollo的网站上详细地介绍了Server和Client的建立方法。

    • Angular + Apollo Client

 

    • Angular + Apollo Client の Subscriptions

 

    • Apollo Server: Understanding schema concepts

Graphql Subscriptions

这个示例实现将这片区域的碎片化信息整理成一本并可操作的样本说明。
此外,根据上述Apollo Client的文档,每个使用的框架都有自己单独的文章,例如上述的Subscriptions页面是Angular的页面,所以如果从React页面跳转到Subscriptions,就会有React的指南。你做得很棒哦~

操作方法

我认为先试着运行一下会更快,所以按照以下步骤启动。

启动GraphQL服务器。

$ npm run server

GraphQL服务器启动,并且http://localhost:4000/graphql成为端点。
此外,ws://localhost:4000/graphql在同一地址成为订阅端点。

直接在浏览器中打开http://localhost:4000/graphql,您可以看到Apollo的GraphQL工作坊页面,您也可以在此直接执行GraphQL API。

Angular 服务器的启动

$ npm run start

那么,通常的ng serve就可以运行了。等待URL是http://localhost:4200。
如果在浏览器中键入,将会打开一个简单的界面。

这个结构简洁,包含了书籍的作者、标题列表,注册表单和删除按钮。

开发流程

我会逐步说明具体的开发流程。

准备

像往常一样,使用@angular/cli进行创建。

$ ng new nGraphQL
$ cd nGraphQL

在 Angular 系列项目中,通常使用”ng”作为项目名。我尝试将其改为”nGraphQL”,但并没有特别的意义。

安装必要的软件包。

$ npm install -S graphql apollo-server-express apollo-client apollo-angular apollo-angular-link-http apollo-cache-inmemory apollo-link-ws

请检查 package.json 文件以查看实际依赖包和脚本。

GraphQL 服务器的实现

本文将详细说明使用Apollo + Express实现GraphQL服务器的步骤。

服务器程序存放处被轻易标记为”server”。

$ mkdir server && cd server

首先,本次要制作的样本模型定义如下。

export interface BookEntity {
  id: string;
  author: string;
  title: string;
}

export const bookFields = `
  id: ID
  title: String
  author: String
`;

我定义了一个只由id、author和title三个字段组成的简单模型作为接口。此外,为了在下面的GraphQL模式定义中使用这三个字段,我将它们导出为一个称为bookFields的常量,该常量只包含字段名称和类型的字符串。值得注意的是,在GraphQL中,有一个有趣的类型,即ID类型。有了这个类型,我们就能够确定数据在哪里是唯一的。

接下来是关于GraphQL的定义。

import { gql } from 'apollo-server-express';
import { bookFields } from './BookEntity';

export const TypeDefs = gql`
  type Query {
    books(author: String): [Book]
  }
  type Mutation {
    book(item: BookEntry!): Book
    removeBook(item: BookEntry!): Book
  }
  type Subscription {
    bookChanged: Book
  }
  type Book {
    ${bookFields}
  }
  input BookEntry {
    ${bookFields}
  }
`;

在 Query 中定义了一个以 author 为条件的搜索查询 API。
在 Mutation 中定义了接收 BookEntry 的新建书籍的 createBook 和删除书籍的 removeBook。
在 Subscription 中定义了当 BookEntry 发生变化时会推送的 bookChanged。

即使字段完全相同,输入值的类型也必须根据type和input进行分开。

让我们先来看一下执行此GraphQL的直接解析器,而不是通过服务。

import {
  BookServiceWithPub,
  pubsub,
  BOOK_INSERT,
  BOOK_DELETE
} from './BookPublishService';

const bookService = new BookServiceWithPub();

export const resolvers = {
  Query: {
    books: (obj, args, context, info) => {
      return bookService.findByAuthor(args.author);
    }
  },
  Mutation: {
    book: (obj, args, context, info) => {
      return bookService.update(args.item);
    },
    removeBook: (obj, args, context, info) => {
      return bookService.delete(args.item);
    }
  },
  Subscription: {
    bookChanged: {
      subscribe: () => pubsub.asyncIterator([BOOK_INSERT, BOOK_DELETE])
    }
  }
};

这是与上述GraphQL模式定义相对应的文件。这个解析器是Apollo直接绑定的。我们从这里调用各种服务。

以下是从这里调用的服务类:

import { PubSub } from 'graphql-subscriptions';
import { BookRepository } from './BookRepository';
import { BookEntity } from './BookEntity';

export const BOOK_INSERT = 'book_event_success_inserted';
export const BOOK_DELETE = 'book_event_success_deleted';
export const pubsub = new PubSub();
export class BookServiceWithPub {
  repo = new BookRepository();

  update(entity: BookEntity) {
    const ret = this.repo.change(entity);
    pubsub.publish(BOOK_INSERT, { bookChanged: ret });
    return ret;
  }

  findByAuthor(author) {
    return this.repo.findByAuthor(author);
  }

  delete(entity: BookEntity) {
    const ret = this.repo.delete(entity);
    pubsub.publish(BOOK_DELETE, { bookChanged: ret });
    return ret;
  }
}

在更新和删除时,重点是执行 pubsub.publish 的部分。被发布的对象也需要按照 {方法名:返回值} 的格式。

请在以后的BookEntryRepository中编写数据库写入等处理。由于这是一个示例,所以我们当前直接将数据保存为一个数组。。。

而本堡,是服务器的index.ts文件。

import * as express from 'express';
import { createServer } from 'http';
import { ApolloServer } from 'apollo-server-express';
import { TypeDefs } from './schema';
import { resolvers } from './resolvers';

const PORT = 4000;

const app = express();

process.on('uncaughtException', err => console.error(err));
process.on('unhandledRejection', err => console.error(err));
// CORSを許可する
app.use(function(req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header(
    'Access-Control-Allow-Headers',
    'Origin, X-Requested-With, Content-Type, Accept'
  );
  next();
});
// resolvers
const server = new ApolloServer({ typeDefs: TypeDefs, resolvers });
server.applyMiddleware({ app, path: '/graphql' });

try {
  // ws_server
  const httpServer = createServer(app);

  server.installSubscriptionHandlers(httpServer);

  httpServer.listen({ port: PORT }, err => {
    console.log(
      `? Server ready at http://localhost:${PORT}${server.graphqlPath}`
    );
    console.log(
      `? Websocket Server is now running on ws://localhost:${PORT}${
        server.subscriptionsPath
      }`
    );
    if (err) {
      throw new Error(err);
    }
  });
} catch (error) {
  console.error(error);
}

在这里要做的是:
1. 创建一个由”const app = express();”生成的Express实例
2. 将该实例通过”server.applyMiddleware({ app, path: ‘/graphql’ });”传递给Apollo-Server
3. 通过”const httpServer = createServer(app);”再创建一个httpServer实例
4. 将该实例通过”server.installSubscriptionHandlers(httpServer);”将其嵌入Apollo-Server作为用于订阅的服务器。

这一点是以两个部分构成的。

查询和变异的API通常使用普通的HTTP,但订阅则使用WebSocket来从服务器进行推送。因此似乎需要另一个服务器实例。

另外,由于Angular的网址和端口不同,因此会受到跨域限制的约束。因此,尽管是一个多合一的解决方案,但我们已经添加了CORS允许。

最后,将tsconfig.json文件中的module修正为commonjs。

    "module": "commonjs",

按照上述的步骤进行。 de .)

$ ts-node server/index.ts

我們將使用Express來啟動Apollo伺服器。
請將此指令添加到package.json的server中。

Angular客户端的实现

在src/app/下将进行客户端以下的实现。

$ cd src/app

首先,我們從這裡開始核心模型的定義。

export interface BookEntity {
  id: string;
  author: string;
  title: string;
}

export const bookFields = `
  id
  title
  author
`;

可以写与服务器端相同的定义,但由于不需要输入的定义,所以只需要bookFields的定义即可。

接下来是Service端的第一种实现。
首先调用查询,并在queryRef.valueChanges中接收结果。

...
export class BookService {
  constructor(private apollo: Apollo, private bookSubs: BookSubscriber) {}

  searchBooks(term: String): Observable<Map<String, BookEntity>> {
    if (!term.trim()) {
      term = '*';
    }
    const queryRef = this.apollo.watchQuery<any>({
      query: gql`{
          books(author:"${term}"){
            ${bookFields}
          }
        }`
    });
    // 追加でSubScribe
    queryRef.subscribeToMore(this.bookSubs.subscribeUpdateBooks());

    return queryRef.valueChanges.pipe(
      map(
        result =>
          result.data.books.reduce((m, o) => {
            m[o.id] = o;
            return m;
          }, {}) as Map<String, BookEntity>
      )
    );
  }

在通常的查询(Query)之后使用watchQuery函数,并向该QueryRef引用添加一个通过SubscribeToMore函数来监听的查询(SubscribeToMore)。
后续将提供SubscribeToMore函数的具体内容。
将从服务器发送的结果转换为Map类型可能不太容易理解。

接下来是更新处理。

  updateBook(id: string, author: string, title: string) {
    return this.apollo
      .mutate({
        mutation: gql`
          mutation CreateBook($book: BookEntry!) {
            book(item: $book) {
              ${bookFields}
            }
          }
        `,
        variables: {
          book: {
            id: id,
            author: author,
            title: title
          }
        }
      })
      .subscribe(() => {
        this.apollo.getClient().cache.reset();
      });
  }

使用 mutate 方法的第二个参数是传递给 variables 的。当然,这个参数的结构必须按照 GraphQL schema 的要求进行设置。由于它是 any 类型,所以无法在 VS Code 中享受到代码自动补全的好处,这是一个不太方便的地方。从 GraphQL 服务器的示例页面中复制并粘贴会很好。
发送 Mutation 查询后,有更新的记录将通过 SubScription 进行通知。因此,在这里我们只需要清除返回值,而不需要接收它,并清除 apollo 查询缓存。
删除操作也是同样的原理,省略不提。

接下来是SubScription的部分。

...
const BOOKS_SUBSCRIPTION = gql`
  subscription bookChanged{
    bookChanged{
      ${bookFields}
    }
  }
`;

@Injectable({
  providedIn: 'root'
})
export class BookSubscriber {
  subscribeUpdateBooks<T extends BookEntity[]>() {
    return {
      document: BOOKS_SUBSCRIPTION,
      updateQuery: (prev, { subscriptionData }) => {
        if (!subscriptionData.data) {
          return prev;
        }
        const bookEntry = subscriptionData.data.bookChanged;
        if (bookEntry != null) {
          const books = prev.books as T;
          const targets = books.filter(e => e.id == bookEntry.id);
          if (targets.length == 0) {
            books.push(bookEntry);
          } else {
            delete books[books.indexOf(targets[0])];
          }
          return prev;
        } else {
          console.error('called updateQuery but bookChanged is null...');
        }
      }
    };
  }
}

这里的棘手之处在于,Query函数接收一个数组作为输入,而Subscription函数只会收到单个记录。prev.books中包含了Query结果的列表,因此我们需要将收到的单个记录与整个列表进行正确的维护。有点像重复劳动的感觉…

最终是作为View后台的组件。由于Query和Subscription之间的通信关系相对疏远,这里感觉非常清爽。

import { Component, OnInit } from '@angular/core';
import { Observable, Subject } from 'rxjs';

import { debounceTime, switchMap } from 'rxjs/operators';

import { BookEntity } from '../service/book';
import { BookService } from '../service/book.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  title = 'GraphQLBooks';
  books: Observable<Map<String, BookEntity>>;

  private searchTerms = new Subject<string>();

  constructor(private service: BookService) {}

  search(term: string): void {
    this.searchTerms.next(term);
  }

  ngOnInit(): void {
    this.books = this.searchTerms.pipe(
      debounceTime(300),
      switchMap((term: string) => this.service.searchBooks(term))
    );
  }

  add(author: string, title: string) {
    this.service.updateBook('', author, title).unsubscribe();
  }

  update(id: string, author: string, title: string) {
    this.service.updateBook(id, author, title).unsubscribe();
  }

  remove(id: string) {
    this.service.removeBook(id).unsubscribe();
  }
}

由于视图的HTML代码糟糕,我们将省略其发布…

当一切实现完成后,可以通过ng start启动Angular服务器。

让我们从浏览器打开 http://localhost:4200 并检查其运行情况。

最后

我在这次中没有找到关于Subscription的客户端和服务器的详细信息,所以经过了一番试错。
通过查看类似Github的API,确实感觉之前的REST API有些困难呢…能够传达这个感觉!

广告
将在 10 秒后关闭
bannerAds