使用Apollo Router将GraphQL服务器配置为微服务架构

前言

由于Apollo Router的使用,可以进行GraphQL + 微服务的系统开发,所以我实际上创建了一个简单的应用程序并进行了测试。

目的受众

    • GraphQLについて基礎的な知識がある方

 

    • Apollo Federation及びApollo Routerを使用してみたい方

 

    GraphQLを使用してマイクロサービスを構築したい方

這篇文章的目標

这次我们将使用Apollo Router开发一个可以创建商品评论的应用程序。

作为条件

    • マイクロサービスで作成する

 

    • 各アプリケーションはGraphQLで通信する

 

    DBは何でも良いが、関係性の解決はApollo Routerにやらせる

我会根据要求进行创建。

为了最小化数据模型,只需创建以下3个要素。

    • レビューを行うユーザー

 

    • レビュー対象の商品

 

    レビュー自体

我们将按照以下系统架构来构建应用程序。

archtecture.png

解释术语

阿波罗路由器是什么?

Appollo Router是一个用于处理Supergraph的Rust制图路由器,它是通过Elastic Licence v2.0保护的开源软件。

由于出现了许多类似Supergraph的Apollo联邦专业术语,因此请参阅以下阐述个Apollo联邦的部分以获取详细信息。

阿波罗联邦是什么?

根据官方网页显示…

Apollo联邦是一个强大的、开放的架构,用于创建一个超级图,将多个GraphQL API 整合在一起。

这是指可以将单独的GraphQL模式合并为一个架构。

将用于合并的各个GraphQL模式称为子图(Subgraph),合并后的一个GraphQL模式称为超图(Supergraph)。

用GraphQL的DSL可以通过Apollo Federation特有的指令来表达这两种方式。

以下是从官方页面中提取的部分内容。

# subgraph
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0",
        import: ["@key", "@shareable", "@provides", "@external"])

type Product @key(fields: "upc") {
  upc: String!
  reviews: [Review]
}

# supergraph
schema
  @link(url: "https://specs.apollo.dev/link/v1.0")
  @link(url: "https://specs.apollo.dev/join/v0.2", for: EXECUTION)
{
  query: Query
}

directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR

type Product
  @join__type(graph: PRODUCTS, key: "upc")
  @join__type(graph: REVIEWS, key: "upc")
{
  upc: String!
  name: String! @join__field(graph: PRODUCTS)
  price: Int @join__field(graph: PRODUCTS)
  reviews: [Review] @join__field(graph: REVIEWS)
}

我认为你可能已经注意到了使用类似于@key和@join__type这样的专用指令。
我希望在实际使用这些指令的章节中进行解释它们各自的含义。

试着实际使用一下

开发环境

那么,现在我们想要使用Apollo Federation来开发应用程序。

我们本次使用的开发环境如下所示。

    • OS: macOS Ventura 13.0.1

 

    • IDE: VS Code 1.75.0

 

    • Docker Engine: v20.10.22

 

    App Platform: Deno v1.30.3

开发各项服务应用程序

创建模式定义

首先,我们将使用GraphQL来创建模式定义。
我们将按照子图 -> 超图的顺序进行创建。

创建子图

将根据第一个撰写评论的用户的架构定义进行创建。

extend schema
  @link(
    url: "https://specs.apollo.dev/federation/v2.0"
    import: ["@key"]
  )

type Query {
  getUser(id: ID!): User
}

type Mutation {
  createUser(name: String!): User
}

type User @key(fields: "id") {
  id: ID!
  name: String!
}

用户信息仅包含名字,设计简单。

在 “extend schema” 行中,通过 Apollo Federation 导入了特定的指令 “@key”。

通过在类型定义上添加@key指令,可以定义实体。

在定义Supergraph时,会解释如何使用实体。

接下来,我们将定义作为审查对象的商品架构。

extend schema
  @link(
    url: "https://specs.apollo.dev/federation/v2.0"
    import: ["@key", "@shareable"]
  )

type Query {
  getProduct(id: ID!): Product
}

type Mutation {
  createProduct(name: String!, price: Int!): Product
}

type Product @key(fields: "id") {
  id: ID!
  name: String!
  price: Int!
}

这个也是一个只有名称和价格的简单设计。

接下来,我们将定义关于评论本身的架构。

extend schema
  @link(
    url: "https://specs.apollo.dev/federation/v2.0"
    import: ["@key", "@shareable", "@provides", "@external"]
  )

type Query {
  getReview(id: ID!): Review
  latestReviews: [Review!]!
}

type Mutation {
  createReview(
    productId: ID!
    userId: ID!
    score: Int!
    description: String!
  ): Review
}

type Product @key(fields: "id") {
  id: ID!
}

type User @key(fields: "id") {
  id: ID!
}

type Review {
  id: ID!
  score: Int!
  description: String!
  product: Product!
  user: User!
}

与之前的两个不同的是,这里定义了实体之间的关联。

此外,我們僅定義了用於用戶和商品ID的另一個架構。這是為了遵循Apollo聯邦的關注點分離設計原則而創建的。

简单来说,只需要从管理评论的应用程序中表达哪位用户对哪个商品创建了评论的部分,而省略其他信息即可。

然而,从API角度来说,在获取数据时,最好能够一起获取,这样会更加方便。因此,我们将在后面的Supergraph中进行表述。

建立Supergraph

好的,现在我们已经创建了创建应用程序所需的Subgraph,接下来我想创建Supergraph并连接模式。

下面是我实际创建的Supergraph。

schema
  @link(url: "https://specs.apollo.dev/link/v1.0")
  @link(url: "https://specs.apollo.dev/join/v0.2", for: EXECUTION) {
  query: Query
}

directive @join__field(
  graph: join__Graph!
  requires: join__FieldSet
  provides: join__FieldSet
  type: String
  external: Boolean
  override: String
  usedOverridden: Boolean
) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION

directive @join__graph(name: String!, url: String!) on ENUM_VALUE

directive @join__implements(
  graph: join__Graph!
  interface: String!
) repeatable on OBJECT | INTERFACE

directive @join__type(
  graph: join__Graph!
  key: join__FieldSet
  extension: Boolean! = false
  resolvable: Boolean! = true
) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR

directive @link(
  url: String
  as: String
  for: link__Purpose
  import: [link__Import]
) repeatable on SCHEMA

scalar join__FieldSet

enum join__Graph {
  USERS @join__graph(name: "users", url: "http://user-app")
  PRODUCTS @join__graph(name: "products", url: "http://product-app")
  REVIEWS @join__graph(name: "reviews", url: "http://review-app")
}

scalar link__Import

enum link__Purpose {
  """
  `SECURITY` features provide metadata necessary to securely resolve fields.
  """
  SECURITY
  """
  `EXECUTION` features provide metadata necessary for operation execution.
  """
  EXECUTION
}

type Mutation {
  createUser(name: String!): User @join__field(graph: USERS)
  createProduct(name: String!, price: Int!): Product
    @join__field(graph: PRODUCTS)
  createReview(
    productId: ID!
    userId: ID!
    score: Int!
    description: String!
  ): Review @join__field(graph: REVIEWS)
}

type User
  @join__type(graph: USERS, key: "id")
  @join__type(graph: REVIEWS, key: "id") {
  id: ID!
  name: String! @join__field(graph: USERS)
}

type Query
  @join__type(graph: REVIEWS)
  @join__type(graph: PRODUCTS)
  @join__type(graph: USERS) {
  getUser(id: ID!): User @join__field(graph: USERS)
  getProduct(id: ID!): Product @join__field(graph: PRODUCTS)
  getReview(id: ID!): Review @join__field(graph: REVIEWS)
  latestReviews: [Review] @join__field(graph: REVIEWS)
}

type Review @join__type(graph: REVIEWS, key: "id") {
  id: ID!
  score: Int!
  description: String!
  product: Product! @join__field(graph: REVIEWS)
  user: User! @join__field(graph: REVIEWS)
}

type Product
  @join__type(graph: PRODUCTS, key: "id")
  @join__type(graph: REVIEWS, key: "id") {
  id: ID!
  name: String! @join__field(graph: PRODUCTS)
  price: Int! @join__field(graph: PRODUCTS)
}

相比于子图,描述的内容大大增加了。
也添加了许多专用的指令。

我想挑选出重要的部分进行解释。

指定Subgraph的解决方式

Supergraph连接子图来进行定义,但实际解决请求的是每个子图的应用程序。
因此,需要定义每个应用程序解决请求的端点。

enum join__Graph {
  USERS @join__graph(name: "users", url: "http://user-app")
  PRODUCTS @join__graph(name: "products", url: "http://product-app")
  REVIEWS @join__graph(name: "reviews", url: "http://review-app")
}

如上所述,将使用@join_graph指令在端点和Supergraph上设置名称。

实体的整合。

在创建子图的过程中,我们使用了@key指令来定义实体。
在超图中,根据@key指令定义的键信息来合并实体。

type Product
  @join__type(graph: PRODUCTS, key: "id")
  @join__type(graph: REVIEWS, key: "id") {
  id: ID!
  name: String! @join__field(graph: PRODUCTS)
  price: Int! @join__field(graph: PRODUCTS)
}

在上述例子中,我们将商品模式和评论模式的实体进行整合,定义了一个新的商品模式。在这个过程中,我们需要使用@join__type指示符来描述实体是在哪个子图中定义的,以及键是什么。

如果存在不属于共同项目的属性,则需要通过@join__field指令定义从哪个子图中获取的信息。

@join__field指令也用于定义Query或Mutation在哪个Subgraph中定义。

type Query
  @join__type(graph: REVIEWS)
  @join__type(graph: PRODUCTS)
  @join__type(graph: USERS) {
  getUser(id: ID!): User @join__field(graph: USERS)
  getProduct(id: ID!): Product @join__field(graph: PRODUCTS)
  getReview(id: ID!): Review @join__field(graph: REVIEWS)
  latestReviews: [Review] @join__field(graph: REVIEWS)
}

type Mutation {
  createUser(name: String!): User @join__field(graph: USERS)
  createProduct(name: String!, price: Int!): Product
    @join__field(graph: PRODUCTS)
  createReview(
    productId: ID!
    userId: ID!
    score: Int!
    description: String!
  ): Review @join__field(graph: REVIEWS)
}

实现后端应用程序

在这个阶段,GraphQL模式定义已经完成,现在我们要开始实现一个接收GraphQL请求的应用程序。

基本上我们会使用以下的库来为Deno实现一个Graphql服务器。

    • Apollo Server

 

    • @graphql-tools/load-files

 

    denodb

我将在下面提供实际实施的服务器应用程序源代码。(由于内容几乎相同,我将提供商品应用程序的源代码作为代表)

import { Env } from "https://deno.land/x/env@v2.2.0/env.js";
import { ApolloServer } from "npm:@apollo/server";
import { startStandaloneServer } from "npm:@apollo/server/standalone";
import { buildSubgraphSchema } from "npm:@apollo/subgraph";
import { loadFiles } from "npm:@graphql-tools/load-files";
import {
  Database,
  PostgresConnector,
} from "https://deno.land/x/denodb@v1.2.0/mod.ts";
import Product from "./models/product.ts";

const env = new Env();

const connector = new PostgresConnector({
  database: env.require("DATABASE_NAME"),
  host: env.require("DATABASE_HOST"),
  username: env.require("DATABASE_USER"),
  password: env.require("DATABASE_PASS"),
  port: 5432,
});

const typeDefs = await loadFiles("schema.graphql");

const resolvers = {
  Query: {
    async getProduct(parent: any, args: any, context: any, info: any) {
      return await Product.find(args.id);
    },
  },
  Mutation: {
    async createProduct(parent: any, args: any, context: any, info: any) {
      let newProduct = new Product();
      newProduct.name = args.name;
      newProduct.price = args.price;
      return await newProduct.save();
    },
  },
  Product: {
    async __resolveReference(product: Product, context: any) {
      return await Product.find(product.id);
    },
  },
};

if (import.meta.main) {
  const server = new ApolloServer({
    schema: buildSubgraphSchema({ typeDefs, resolvers }),
  });

  const db = new Database(connector);
  await db.link([Product]);
  await db.sync();

  const { url } = await startStandaloneServer(server, {
    listen: env.require("APP_PORT"),
  });
  console.log(`?  Server ready at ${url}`);
}

GraphQL API的实现是在解析器(resolvers)部分进行的。
基本上,在Query和Mutation中,我们实际上会实现根据GraphQL模式定义的查询的方法。

由于只实现了一个简单的功能,即注册或参考请求内容的数据,因此处理内容仅限于向数据库注册和参考。

顺便说一句,我们通过使用denodb来进行数据库访问和创建模型类来实现。举个例子,商品模型的模型类如下所示。

import { DataTypes, Model } from "https://deno.land/x/denodb@v1.2.0/mod.ts";

export default class Product extends Model {
  static table = "products";

  static fields = {
    id: {
      type: DataTypes.INTEGER,
      primaryKey: true,
      autoIncrement: true,
    },
    name: DataTypes.STRING,
    price: DataTypes.INTEGER,
  };

  id!: number;
  name!: string;
  price!: number;
}

子圖的實現與常規解析器有所不同。
這是指實體屬性和 __resolveReference 方法。

  Product: {
    async __resolveReference(product: Product, context: any) {
      return await Product.find(product.id);
    },
  },

在这部分中,我们定义了用于通过Supergraph解决实体的方法。
在上述示例中,当解决商品实体时,会传递id作为查询,并根据此id从数据库中获取数据并返回该实现。
如果想要将id部分改为其他值,可以在GraphQL模式的@key指令中更改设置字段。

试着让它动起来

集装箱的准备 de

我已经完成了应用程序的实施,现在我想确认一下从这里开始,实际运行该应用程序并检查Supergraph是否按预期运行。

由于这次我们采用了微服务架构,因此在应用程序的构建中将使用docker-compose。

Apollo Router 创建容器定义

我們將立即準備Apollo Router的容器。
Dockerfile的內容將參考Apollo GraphQL的GitHub頁面進行創建。

FROM debian:bullseye-slim

ARG ROUTER_RELEASE=latest
ARG DEBUG_IMAGE=false

WORKDIR /dist

# Install curl
RUN \
  apt-get update -y \
  && apt-get install -y \
  curl

# If debug image, install heaptrack and make a data directory
RUN \
  if [ "${DEBUG_IMAGE}" = "true" ]; then \
  apt-get install -y heaptrack && \
  mkdir data; \
  fi

# Clean up apt lists
RUN rm -rf /var/lib/apt/lists/*

# Run the Router downloader which puts Router into current working directory
RUN curl -sSL https://router.apollo.dev/download/nix/${ROUTER_RELEASE}/ | sh

# Make directories for config and schema
RUN mkdir config schema

# Copy configuration for docker image
COPY router.yml config

LABEL org.opencontainers.image.authors="Apollo Graph, Inc. https://github.com/apollographql/router"
LABEL org.opencontainers.image.source="https://github.com/apollographql/router"

ENV APOLLO_ROUTER_CONFIG_PATH="/dist/config/router.yaml"

COPY supergraph-schema.graphql schema

# Create a wrapper script to run the router, use exec to ensure signals are handled correctly
RUN \
  echo '#!/usr/bin/env bash \
  \nset -e \
  \n \
  \nif [ -f "/usr/bin/heaptrack" ]; then \
  \n    exec heaptrack -o /dist/data/router_heaptrack /dist/router "$@" \
  \nelse \
  \n    exec /dist/router "$@" \
  \nfi \
  ' > /dist/router_wrapper.sh

# Make sure we can run our wrapper
RUN chmod 755 /dist/router_wrapper.sh

# Default executable is the wrapper script
ENTRYPOINT ["/dist/router_wrapper.sh", "--config", "/dist/config/router.yml", "--supergraph", "/dist/schema/supergraph-schema.graphql"]

请确保在此之前创建的Supergraph的模式定义文件被包含在内(Apollo路由器会读取并运行Supergraph的模式定义文件)。

另外,我们还将包含Apollo Router的配置文件。由于我们想要使用Apollo Sandbox来进行操作验证,因此我们将记录最基本的配置。

# ---router.yml---

# Configuration of the router's HTTP server
# Default configuration for container
supergraph:
  # The socket address and port to listen on
  listen: 0.0.0.0:80
  introspection: true

sandbox:
  enabled: true

# Sandbox requires the default landing page to be disabled.
homepage:
  enabled: false

创建用于Subgraph应用程序的容器定义

然后创建一个用于接收Apollo Router请求的后端应用程序容器定义。
只要内容适用于Deno的运行,就以denoland的图像形式打包应用程序进行定义。

FROM denoland/deno:1.30.3

# The port that your application listens to.
EXPOSE 80

WORKDIR /app

# Prefer not to run as root.
USER deno

# These steps will be re-run upon each file change in your working directory:
COPY src/ .
# Compile the main app so that it doesn't need to be compiled each startup/entry.
RUN deno cache main.ts

CMD ["run", "--allow-net", "--allow-env", "--allow-read", "main.ts"]

创建docker-compose.yml文件

最后,把创建好的容器定义整理到docker-compose文件中,就完成了。

version: '3.1'
services:
  db:
    image: postgres
    restart: always
    ports:
      - 5432:5432
    environment:
      POSTGRES_DB: $DATABASE_NAME
      POSTGRES_PASSWORD: $DATABASE_PASS
      POSTGRES_USER: $DATABASE_USER
  
  router:
    build:
      context: .
      dockerfile: ./Dockerfile.router
    ports:
        - 8080:80
    depends_on:
      - user-app
      - product-app
      - review-app

  user-app:
    build:
      context: users
      dockerfile: ../Dockerfile.deno
    environment:
      DATABASE_NAME: $DATABASE_NAME
      DATABASE_USER: $DATABASE_USER
      DATABASE_PASS: $DATABASE_PASS
      DATABASE_HOST: db
      APP_PORT: $APP_PORT
    ports:
      - 80
    depends_on:
      - db
  
  product-app:
    build:
      context: products
      dockerfile: ../Dockerfile.deno
    environment:
      DATABASE_NAME: $DATABASE_NAME
      DATABASE_USER: $DATABASE_USER
      DATABASE_PASS: $DATABASE_PASS
      DATABASE_HOST: db
      APP_PORT: $APP_PORT
    ports:
      - 80
    depends_on:
      - db

  review-app:
    build:
      context: reviews
      dockerfile: ../Dockerfile.deno
    environment:
      DATABASE_NAME: $DATABASE_NAME
      DATABASE_USER: $DATABASE_USER
      DATABASE_PASS: $DATABASE_PASS
      DATABASE_HOST: db
      APP_PORT: $APP_PORT
    ports:
      - 80
    depends_on:
      - db

volumes:
  graphql-supergraph-test-vol:

确认行动

因为以上事项已经准备就绪,所以立即执行 docker-compose up -d 命令启动应用程序。

当Apollo Router启动完成后,您可以在浏览器中打开 http://localhost:8080 进行访问。

因为这次启用了Apollo沙箱,所以会显示以下类型的屏幕。

スクリーンショット 2023-02-27 13.16.28.png

您可以在此画面上创建GraphQL查询并点击执行按钮,将查询发送到Apollo路由器。

返回的响应将显示在右侧的响应面板上。

那么,让我们实际发出查询并确认结果。

创建用户

首先,我们需要创建用户来编写评论。
使用以下查询来执行:

mutation CreateUser {
    createUser(name: "test-user") {
        id
    }
}

实际上我创建了用户,结果如下。

スクリーンショット 2023-02-27 13.25.43.png

由于仅指定了id字段作为响应,因此仅返回了创建用户的ID。

为了确认,我们也将查询用户的参考信息。我们将使用以下的查询语句。

query getUser {
    getUser(id: 1) {
        name
    }
}

结果如下。

スクリーンショット 2023-02-27 13.30.23.png

在上述的结果中,我们确认了Apollo Router与用户应用程序之间的互通。

商品的制作

下一步是创建产品的审核对象。
我们使用以下查询。

mutation CreateProd {
    createProduct(name: "test-prod", price: 100) {
        id
    }
}

执行结果如下所示。

スクリーンショット 2023-02-27 13.34.23.png

我会尽量参考一下。
以下是使用的查询和结果。

query GetProd {
    getProduct(id: 1) {
        name
        price
    }
}
スクリーンショット 2023-02-27 13.37.18.png

我已确认能够与商品应用程序进行通讯。

撰写评论

最后我会撰写一篇评论。

以下是要使用的查询。

mutation CreateReview {
    createReview(
        productId: 1,
        userId: 1,
        score: 5,
        description: "期待を込めて星5です") {
            id
    }
}

结果如下所示。

スクリーンショット 2023-02-27 13.42.47.png

到目前为止,我们确认已经与评论应用程序建立了通信。
接下来的部分将使用Apollo Router的方便功能。

获取评论(让Apollo Router获取实体)

我已经创建了一些数据,但还没有进行审核引用。
那么,我们现在就开始创建用于参考审核的查询吧。

query GetReview {
    getReview(id: 1) {
        score
        description
        user {
            name
        }
        product {
            name
            price
        }
    }
}

这个查询用于获取作为评审数据的用户和目标商品信息。getReview查询本身就是在评审应用程序中定义的查询,用户和商品的信息应该只有ID。

然而,由于在Supergraph中合并了实体,Apollo Router通过向每个应用程序发送查询来获取所需的数据,并将其全部合并在响应中返回。

以下是实际执行的结果。

スクリーンショット 2023-02-27 13.53.39.png

准确地合并了指定的所有数据并返回给了我。
通过这个功能,我们可以在微服务中开发应用程序时,实现对每个应用程序进行数据的集中管理,并且能够将客户端的请求合并到一个端点上。

最终的文件夹结构

我们创建的成果物文件夹结构如下所示。

.
├── Dockerfile.deno
├── Dockerfile.router
├── docker-compose.yml
├── products
│   └── src
│       ├── main.ts
│       ├── models
│       │   └── product.ts
│       └── schema.graphql
├── reviews
│   └── src
│       ├── main.ts
│       ├── models
│       │   └── review.ts
│       └── schema.graphql
├── router.yml
├── supergraph-schema.graphql
└── users
    └── src
        ├── main.ts
        ├── models
        │   └── user.ts
        └── schema.graphql

最后

感謝您一直阅读到最后!如果文章中有任何错误的地方,请您与我们联系,我们将不胜感激。

bannerAds