🐈

Next.jsとAppSyncでGraphQLに入門する

2023/05/02に公開

初めに

この度、春のJavaScript祭りOnline 2023というイベントで登壇しました。

https://www.youtube.com/live/T_o6fJ_-39o?feature=share&t=3765

Next.jsとGraphQLでモダンな開発をマスターしようというテーマで発表したのですが、今回はその中でも特にGraphQLに焦点を当てて、簡単にAPIを作る方法について書きます。

スライドやコードは以下から確認いただけます。

https://www.docswell.com/s/yuikoito/Z4QVPJ-2023-05-02-021829
https://github.com/yuikoito/graphql-nextjs-todo-app

この記事を通して作成できるアプリの動作イメージは以下です。

https://youtu.be/6BrWnFj3iCk

GraphQLとは

GraphQLは、複数のRESTエンドポイントの代わりに単一のエンドポイントを提供します。

最初にスキーマを決めてから開発を進めるため、スキーマはドキュメント代わりになるだけでなく、フロントエンドとバックエンドで共通のスキーマに従って開発することができます。そのため、APIの開発が完了するまで待つ必要がなく、並行して開発を進めることができます。

また、必要なデータだけを取得できるため、過剰なネットワークトラフィックを削減できるというのも大きな強みです。

RestAPIとの比較をするなら以下。

とはいえ、学習コストが高いというのがネックになってきます。
そこで、導入に最適なのがAppSyncです。

AppSyncとは

AppSyncとは、導入コストが高いGraphQLを簡単に利用できるAWSのフルマネージドサービスです。

スライドにもある通り、すべてGUIベースでポチポチするだけでAPIの作成からテストまでできるという優れものです。

では実際にAppSyncを使ってAPIを作成してみましょう。

AppSyncを使ってAPIを作成する

スキーマを定義する

まず、AWSの管理画面からAppSyncに入ります。

一から構築を選択して開始をクリックします。
(ちなみにサンプルアプリも多数用意されているので、それを利用するのもよいかもしれません。)

お好きなAPIの名前を決めて、作成をクリックします。

次に、スキーマを編集をクリックして、スキーマを定義します。


今回は以下のスキーマを利用します。

type Mutation {
	createTodo(input: TodoInput!): Todo
	updateTodo(id: ID!, input: TodoInput!): Todo
	deleteTodo(id: ID!): Boolean
}

type Query {
	singleTodo(id: ID!): Todo
	allTodos: [Todo]
}

type Todo {
	id: ID!
	title: String!
	description: String
}

input TodoInput {
	title: String!
	description: String
}

schema {
	query: Query
	mutation: Mutation
}

スキーマの詳しい説明に関しては割愛しますが、要はqueryというのがRestAPIでいうgetで、mutationがdelete、patch、putなどのデータベースに変更を加える役割です。

データを保存するためのテーブルを用意する

スキーマを保存出来たら、次はDBの作成に移ります。
DBに関して、今回はdynamoDBを使うことにします。

dynamoDBの画面にうつって、

パーティションキーをidで設定してテーブルの作成をクリックします。
これでDBの設定自体は完了です。ここで定義したテーブル名はこの後Lambdaとの接続で使うのでどこかにコピーしておいてください。

AppSyncと接続する関数をLambdaで定義する

次に、AppSyncのスキーマに応じてデータベースを操作したいのですが、そのための関数をLambdaで定義します。

管理画面からLambdaに入って、関数を作成をクリックします。

もちろんコンテナと接続してもいいですが、今回は直接Lambda上にnode.jsを書く形で進めたいので、ここでも一から作成をクリックします。

そこでコードを書いていきます。
今回利用したコードは以下です。長くなりすぎるので、defaultで設定されているindex.mjsのほかにgraphql.mjsを作成しました。

index.mjsのコード↓

import * as graphql from "./graphql.mjs";
const { createTodo, updateTodo, deleteTodo, getAllTodos, getTodoById } = graphql;

export async function handler(event) {
  try {
    // クエリを判別
    const query = event.info.fieldName;
    switch (query) {
      case "allTodos":
        return await getAllTodos();
      case "singleTodo":
        return await getTodoById(event.arguments.id);
      case "createTodo":
        return await createTodo(event.arguments.input);
      case "updateTodo":
        return await updateTodo(event.arguments.id, event.arguments.input);
      case "deleteTodo":
        return await deleteTodo(event.arguments.id)
      default:
        // 不明なクエリは異常系と同等
        console.log("Unknown Query is input.");
        return null;
    }
  } catch (error) {
    console.log(error);
    // 異常系はnullを返す
    return null;
  }
}

ここで重要なのは、AppSyncからLambdaを動かすと、リクエストはeventとして飛んできますが、その中のevent.info.fieldNameがAppSyncのスキーマ定義部分で定義したクエリ名になります。例えば、今回だとmutationとqueryで以下を定義していたので、それぞれcreateTodoやupdateTodo等のクエリに応じて、具体的な動作を書けばいいわけです。

type Mutation {
	createTodo(input: TodoInput!): Todo
	updateTodo(id: ID!, input: TodoInput!): Todo
	deleteTodo(id: ID!): Boolean
}

type Query {
	singleTodo(id: ID!): Todo
	allTodos: [Todo]
}

そこで、今回はコードが長すぎるのでgraphql.mjsというファイルを新しく作成して、その中にクエリごとで動かしたい関数を定義することにしました。
LambdaからdynamoDBにアクセスするのは非常に簡単で、以下のように@aws-sdkを利用することができます。

graphql.mjsのコード↓

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient,
  ScanCommand,
  PutCommand,
  UpdateCommand,
  GetCommand,
  DeleteCommand,
} from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const dynamo = DynamoDBDocumentClient.from(client);
const tableName = "ここにテーブル名";

function randomString(length) {
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let result = '';
  for (let i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * characters.length));
  }
  return result;
}


export async function getAllTodos () {
  const params = {
    TableName: tableName,
  };

  try {
    const command = new ScanCommand(params);
    const result = await dynamo.send(command);
    return result.Items;
  } catch (error) {
    console.error("DynamoDB error:", error);
    return [];
  }
};


export async function getTodoById (id) {
  const params = {
    TableName: tableName,
    Key: { id },
  };

  try {
    const command = new GetCommand(params);
    const result = await dynamo.send(command);
    return result.Item;
  } catch (error) {
    console.error("DynamoDB error:", error);
    return null;
  }
};

export async function createTodo(todo) {
  const newId = Date.now().toString(36) + randomString(5); // タイムスタンプと5文字のランダムな文字列を組み合わせたIDを生成
  // undefinedな値を削除する関数
  function removeUndefinedValues(obj) {
    return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined));
  }

  const item = removeUndefinedValues({
    id: newId,
    title: todo.title,
    description: todo.description,
  });

  const params = {
    TableName: tableName,
    Item: item,
  };

  try {
    const command = new PutCommand(params);
    const result = await dynamo.send(command);
    return {
      id: newId,
      title: todo.title,
      description: todo.description,
    }
  } catch (error) {
    console.error("DynamoDB error:", error);
    return null;
  }
}

export async function updateTodo(id, input) {
  // Generate UpdateExpression and ExpressionAttributeValues dynamically
  const updateExpressions = [];
  const expressionAttributeValues = {};

  for (const key in input) {
    if (input.hasOwnProperty(key)) {
      updateExpressions.push(`${key} = :${key}`);
      expressionAttributeValues[`:${key}`] = input[key];
    }
  }

  const params = {
    TableName: tableName,
    Key: { id },
    UpdateExpression: `SET ${updateExpressions.join(", ")}`,
    ExpressionAttributeValues: expressionAttributeValues,
    ReturnValues: "ALL_NEW",
  };

  try {
    const command = new UpdateCommand(params);
    const result = await dynamo.send(command);
    return result.Attributes;
  } catch (error) {
    console.error("DynamoDB error:", error);
    return null;
  }
}


export async function deleteTodo(id) {
  const params = {
    TableName: tableName,
    Key: { id }
  };

  try {
    const command = new DeleteCommand(params);
    await dynamo.send(command);
    return true;
  } catch (error) {
    console.error("DynamoDB error:", error);
    return false;
  }
}

そしてここまで出来たら、Deployをクリックします。

ただし、このままではタイムアウトする可能性が高いのでタイムアウトの時間を延ばします。
defaultでは3秒ですが、それを最大の30秒にしておきます。

設定→一般設定に進んで、編集をクリックします。

タイムアウトを30秒にして保存します。

また、このままではLambdaやCloudWatchにアクセスする権限がないので、権限をつける必要があります。(CloudWatchは上記コードでログを出しているので、つけているだけです。ログが不要であればこれはなしで大丈夫です。)

設定→アクセス権限に進みます。

実行ロールのロール名にあたるURLをクリックします。

許可ポリシーを追加することができますので、dynamoDBやCloudWatchにアクセスするフルアクセス権限をつけます。

これで、LambdaでdynamoDBに対してデータを投稿したり編集、削除したりするための関数が定義できました。

あとはこれらをAppSyncと接続していきます。

AppSyncとLambdaを紐づける

AppSyncに戻っていただき、クエリとミューテーションにリゾルバーをアタッチしていきます。

直接Lambdaを参照することはできないので、データソース経由で紐づけを行います。
まずはデータソースという項目をクリックして、データソースを作成します。

ここでは単純にdataSourceという名前にしていますが、名前はもちろん何でもOKです。

データソースタイプでLambdaを選択して、さっき作った関数と紐付ける作業をします。
関数のARN部分で先ほど定義したLambdaを指すARNを選択してください。

そしてあとはリゾルバとしてこのデータソースをあてていきます。

スキーマに戻ってアタッチしていきます。
リゾルバーの項目から、アタッチをクリックします。

アクションからランタイムを更新を選択。

リゾルバータイプをUnit Resolverにします。

そこまでできたらあとは先ほど設定したデータソースを選択して保存します。

ここでいうリゾルバとは、フロントからのリクエストに応じてDBに必要なデータを取得しに行くことを指し、Lambdaは踏み台だと考えればわかりやすいかもしれません。

リクエストがフロントからAppSyncにながれ、それに応じてデータソースにアクセスが生じるが、そのデータソースがLambdaと紐づいているため、Lambdaを経由してデータを取得しているイメージです。

上記の手順に関しては以下の動画で一通り見れますのでよければ参考にしてください。
https://youtu.be/xvM5sGTLyjg

これでAPIの作成は完了です。

フロントエンドとの接続作業

これでAPIが完成しましたので、残りはフロントエンドとの接続作業だけですが、AppSyncとの接続は非常に簡単です。
まず、AppSyncのページの中にアプリと統合するという項目がありますので、それに従うことで、codegenを利用して自動で必要なコードを作成することができます。

あとは、その自動生成されたコードを利用して、APIにアクセスします。
aws-amplifyをインストールします。

yarn add aws-amplify

_app.tsxで初期設定を行います。今回はAPIキーでの認証なのですが、必要に応じてcognito経由など選択することができます。

import type { AppProps } from "next/app";
import { Amplify } from "aws-amplify";

Amplify.configure({
  aws_appsync_graphqlEndpoint: process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT,
  aws_appsync_region: "ap-northeast-1",
  aws_appsync_authenticationType: "API_KEY",
  aws_appsync_apiKey: process.env.NEXT_PUBLIC_API_KEY,
});

export default function App({ Component, pageProps }: AppProps) {
  return (
    <Component {...pageProps} />
  );
}

これで例えば全データを取得するためのクエリ(今回だとallTodos)をたたくためのコードは以下のように書けます。

import { API, graphqlOperation } from "aws-amplify";
import { allTodos } from "@/src/graphql/queries";
import { AllTodosQuery, Todo } from "@/src/API";

export const getAllTodos = async () => {
  const response = (await API.graphql(graphqlOperation(allTodos))) as {
    data: AllTodosQuery;
  };
  return response.data.allTodos as Todo[];
};

この中で、@/src/graphql@/src/APIにあたるものはcodegenで自動生成されたものです。つまり、API.graphql(graphqlOperation(ここにクエリ名)と書くだけで、必要なデータが取得できるのです。

非常に楽ですね。

このフロントエンド部分に関しては以下のgithubにアップしており、環境変数を埋め込むだけで使えるようになってますので、よければAppSyncで作ったAPIと接続してみてください。

https://github.com/yuikoito/graphql-nextjs-todo-app

最後に

なかなか導入コストが高いGraphQLですが、AppSyncを使えばインフラ面のことを全く考えることなく、API作成までできるので非常に良いですね。
GraphQLを導入したいけど難しそう、、と思っていた方はこの機会にAppSyncを利用してみてはいかがでしょうか。

Discussion