AWS Amplify & GraphQLでのデータモデル (スキーマ) 設計例をまとめます。
はじめに
こんにちは、@bioerrorlogです。
最近、AWS Amplifyに注目してします。
Amplifyはフルスタックなサーバレスアプリを素早く作ることが出来るプラットフォームで、プロダクト開発の生産性を高めることが出来ます。
AmplifyプロジェクトのAPIを GraphQL (AppSync)で構築するときには、データモデルをスキーマschema.graphql
に定義する流れになります。
このGraphQLのスキーマ設計はリレーショナルデータベースでのデータモデル設計とはやり方が異なるため、最初は慣れが必要です。
今回は、Amplifyのdeveloper advocateであるNader Dabit氏による以下の記事を参考に、GraphQL & Amplifyでのスキーマ設計例を学んでいきます。
GraphQL Recipes (V2) - Building APIs with GraphQL Transform - DEV Community 👩💻👨💻
以下の順にスキーマ例を見ていきます。
スキーマ設計例
Todoアプリ
type Todo @model { id: ID! name: String! description: String }
Todoアプリにおけるユーザーの行動:
- 全TodoをList取得できる
- TodoをCreate/Update/Deleteできる
その実現に必要な要件:
- Todo type
- データベースの作成
- GraphQL mutations(create/update/delete)の定義
- GraphQL queries(listTodos)の定義
- 各処理のためのGraphQL resolversの作成
まずはシンプルなTodoアプリの例です。
上記のスキーマから、GraphQL API(AppSync)やデータベース(DynamoDB)の作成、GraphQL resolversの作成、GraphQL queries/mutations/subscriptionの定義がデプロイできます。
イベントアプリ
type Event @model @key(name: "itemType", fields: ["itemType", "time"], queryField: "eventsByDate") @auth(rules: [ { allow: groups, groups: ["Admin"] }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) { id: ID! name: String! description: String time: String! itemType: String! comments: [Comment] @connection #optional comments field } # Optional Comment type type Comment @model @auth(rules: [ { allow: owner, ownerField: "author" }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) { id: ID! message: String! author: String }
イベントアプリにおけるユーザーの行動:
- 日付順でイベントをList取得できる
- 個々のイベントを確認できる
- イベントに付けられたコメントを確認できる
- サインインしたユーザーはコメントを作成できる
- 管理者はイベントを作成できる
- 管理者はイベントを更新/削除できる
各directives(@key
, @auth
, @connection
)を活用して、上記の要求を満たすスキーマが設計されています。
@key
による定義によって、日付順のイベントList取得を可能にしています。
@key
の使い方はこちら。
@connection
を用いたデータモデル間関係の定義によって、イベントに付けられたコメントの確認を可能にしています。
@connection
の使い方はこちら。
@auth
を用いた権限設定によって、ユーザー毎に可能な操作を制限しています。
@auth
の使い方はこちら。
イベントを作成するときは、以下のようなcreate mutationを発行します。
mutation createEvent { createEvent(input: { name: "Rap battle of the ages" description: "You don't want to miss this!" time: "2018-07-13T16:00:00Z" itemType: "Event" }) { id name description time } }
itemType
に一定の値"Event"
を格納しておくことで、以下のように日付順にソートしたイベントのListが取得出来ます。
query listEvents { eventsByDate(itemType: "Event") { items { id name description time } } }
イベントに紐づいたコメントを作成するには、以下のようなcreate mutationを発行します。
mutation createComment { createComment(input: { eventCommentsId: "7f0d82f5-b57e-4417-b515-ce04475675a2" message:"Amazing!" }) { id message } }
eventCommentsId
を指定することで、コメントを紐づけるイベントを指定することが出来ます。
チャットアプリ
ここではシンプルなチャットアプリについて書きます。 より現実的なチャットアプリについては、後述のマルチユーザーチャットアプリの章にて記述します。
type Conversation @model { id: ID! name: String messages: [Message] @connection(keyName: "messagesByConversationId", fields: ["id"]) createdAt: String updatedAt: String } type Message @model(subscriptions: null, queries: null) @key(name: "messagesByConversationId", fields: ["conversationId"]) { id: ID! conversationId: ID! content: String! conversation: Conversation @connection(fields: ["conversationId"]) createdAt: String }
チャットアプリにおけるユーザーの行動:
- 会話を作成する
- 会話でメッセージを送る
- 全ての会話とメッセージを閲覧する
- 新規メッセージと会話をリアルタイムに確認(
Subscribe
)する
会話の作成、会話に対するメッセージの作成、全ての会話とメッセージの取得のためには、以下のquery / mutationを発行します。
# 会話の作成 mutation createConversation { createConversation(input: { name: "my first conversation" }) { name id } } # 会話に対するメッセージの作成 mutation createMessage { createMessage(input: { conversationId: "your-conversation-id" content: "Hello world" }) { id content } } # 全ての会話とメッセージの取得 query listConversations { listConversations { items { name messages { items { content } } } } }
Eコマースアプリ
type Customer @model(subscriptions: null) @auth(rules: [ { allow: owner }, { allow: groups, groups: ["Admin"] } ]) { id: ID! name: String! email: String! address: String orders: [Order] @connection(keyName: "byCustomerId", fields: ["id"]) } type Product @model(subscriptions: null) @auth(rules: [ { allow: groups, groups: ["Admin"] }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) { id: ID! name: String! description: String price: Float! image: String } type LineItem @model(subscriptions: null) @key(name: "byOrderId", fields: ["orderId"]) @auth(rules: [ { allow: owner }, { allow: groups, groups: ["Admin"] } ]) { id: ID! orderId: ID! productId: ID! qty: Int order: Order @connection(fields: ["orderId"]) product: Product @connection(fields: ["productId"]) description: String price: Float total: Float } type Order @model(subscriptions: null) @key(name: "byCustomerId", fields: ["customerId", "createdAt"], queryField: "ordersByCustomerId") @auth(rules: [ { allow: owner }, { allow: groups, groups: ["Admin"] } ]) { id: ID! customerId: ID! total: Float subtotal: Float tax: Float createdAt: String! customer: Customer @connection(fields: ["customerId"]) lineItems: [LineItem] @connection(keyName: "byOrderId", fields: ["id"]) }
Eコマースアプリにおけるユーザーの行動:
- ユーザーはアカウントを作成できる
- ユーザーは商品を閲覧できる
- ユーザーは注文を作成できる
- ユーザーは購入商品 (line item) を注文に追加できる
- ユーザーは自身のアカウントとそれに紐づいた注文・商品を閲覧できる
- 管理者は商品/注文/ユーザーを作成/更新/削除できる
- 管理者は商品/注文/ユーザーを取得できる
- 管理者はユーザーに紐づいた注文を取得できる
各操作には、以下のGraphQL mutations / queriesを発行します。
mutation createProduct { createProduct(input: { name: "Yeezys" description: "Best shoes ever" price: 200.00 }) { id name description price } } mutation createCustomer { createCustomer(input: { name: "John Doe" email: "johndoe@myemail.com" address: "555 Hwy 88" }) { id email name address } } mutation createOrder { createOrder(input: { subtotal: 250.00 total: 275.00 tax: 25.00 customerId: "some-customer-id" }) { id subtotal tax total customer { name } } } mutation createLineItem { createLineItem(input: { qty: 1 productId: "some-product-id" orderId: "some-order-id" price: 250.00 total: 250.00 }) { id qty } } query getCustomer { getCustomer(id: "some-customer-id") { id name address orders { items { id lineItems { items { description price total qty product { id name description } } } } } } } query ordersByCustomerId { ordersByCustomerId( customerId: "some-customer-id" ) { items { id lineItems { items { id price total } } total subtotal tax } } } query listOrders { listOrders { items { id total subtotal tax lineItems { items { id price product { id price description } } } } } }
WhatsAppクローン
type User @key(fields: ["userId"]) @model(subscriptions: null) @auth(rules: [ { allow: owner, ownerField: "userId" } ]) { userId: ID! avatar: String conversations: [ConvoLink] @connection(keyName: "conversationsByUserId", fields: ["userId"]) messages: [Message] @connection(keyName: "messagesByUserId", fields: ["userId"]) createdAt: String updatedAt: String } type Conversation @model(subscriptions: null) @auth(rules: [{ allow: owner, ownerField: "members" }]) { id: ID! messages: [Message] @connection(keyName: "messagesByConversationId", fields: ["id"]) associated: [ConvoLink] @connection(keyName: "convoLinksByConversationId", fields: ["id"]) members: [String!]! createdAt: String updatedAt: String } type Message @key(name: "messagesByConversationId", fields: ["conversationId"]) @key(name: "messagesByUserId", fields: ["userId"]) @model(subscriptions: null, queries: null) { id: ID! userId: ID! conversationId: ID! author: User @connection(fields: ["userId"]) content: String! image: String conversation: Conversation @connection(fields: ["conversationId"]) createdAt: String updatedAt: String } type ConvoLink @key(name: "convoLinksByConversationId", fields: ["conversationId"]) @key(name: "conversationsByUserId", fields: ["userId"]) @model( mutations: { create: "createConvoLink", update: "updateConvoLink" } queries: null subscriptions: null ) { id: ID! userId: ID! conversationId: ID! user: User @connection(fields: ["userId"]) conversation: Conversation @connection(fields: ["conversationId"]) createdAt: String updatedAt: String } type Subscription { onCreateConvoLink(userId: ID): ConvoLink @aws_subscribe(mutations: ["createConvoLink"]) onCreateMessage(conversationId: ID): Message @aws_subscribe(mutations: ["createMessage"]) }
WhatsAppクローンにおけるユーザーの行動:
- ユーザーはアカウントを作成できる
- ユーザーはプロフィールのアバター画像を更新できる
- ユーザーは会話を作成できる
- ユーザーは会話でメッセージを送れる
Redditクローン
type User @model(subscriptions: null) @key(fields: ["userId"]) @auth(rules: [ { allow: owner, ownerField: "userId" } ]) { userId: ID! posts: [Post] @connection(keyName: "postByUser", fields: ["userId"]) createdAt: String updatedAt: String } type Post @model @key(name: "postByUser", fields: ["authorId", "createdAt"]) @auth(rules: [ { allow: owner, ownerField: "authorId" }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) { id: ID! authorId: ID! author: User @connection(fields: ["authorId"]) postContent: String postImage: String comments: [Comment] @connection(keyName: "commentsByPostId", fields: ["id"]) votes: [PostVote] @connection(keyName: "votesByPostId", fields: ["id"]) createdAt: String voteCount: Int } type Comment @model @key(name: "commentsByPostId", fields: ["postId"]) @auth(rules: [ { allow: owner, ownerField: "authorId" }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) { id: ID! authorId: ID! postId: ID! text: String! author: User @connection(fields: ["authorId"]) votes: [CommentVote] @connection(keyName: "votesByCommentId", fields: ["id"]) post: Post @connection(fields: ["postId"]) voteCount: Int } type PostVote @model @auth(rules: [ { allow: owner, ownerField: "userId"}, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) @key(name: "votesByPostId", fields: ["postId"]) { id: ID! postId: ID! userId: ID! post: Post @connection(fields: ["postId"]) createdAt: String! vote: VoteType } type CommentVote @model @auth(rules: [ { allow: owner, ownerField: "userId"}, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) @key(name: "votesByCommentId", fields: ["commentId"]) { id: ID! userId: ID! commentId: ID! comment: Comment @connection(fields: ["commentId"]) createdAt: String! vote: VoteType } input VoteInput { type: VoteType! id: ID! } enum VoteType { up down }
Redditクローンにおけるユーザーの行動:
- ユーザーはアカウントを作成できる
- ユーザーはポスト(テキストor画像)を作成できる
- ユーザーはポストにコメントできる
- ユーザーはポストをVoteできる
- ユーザーはコメントをVoteできる
ここで、カスタムresolverを実装してVoteのId
をpostId
とuserId
の組み合わせにすることで、ユーザーが一つのポストに複数回Voteするのを防ぐことが出来ます。
カスタムresolverの追加実装例は以下です。
#set($itemId = "$context.identity.username#$context.args.postId") $util.qr($context.args.input.put("id", $util.defaultIfNull($ctx.args.input.id, $itemId)))
そしてVoteの上書きを回避するための以下のコードがあれば、削除orコメントアウトします。
#set( $condition = { "expression": "attribute_not_exists(#id)", "expressionNames": { "#id": "id" } } )
また、Voteカウントを算出したければ、DynamoDBトリガーのLambda関数を作成し、カスタムロジックを実装する必要があります。
Lambda実装例はこちら。
マルチユーザーチャットアプリ
type User @key(fields: ["userId"]) @model(subscriptions: null) @auth(rules: [ { allow: owner, ownerField: "userId" } ]) { userId: ID! conversations: [ConvoLink] @connection(keyName: "conversationsByUserId", fields: ["userId"]) messages: [Message] @connection(keyName: "messagesByUserId", fields: ["userId"]) createdAt: String updatedAt: String } type Conversation @model(subscriptions: null) @auth(rules: [{ allow: owner, ownerField: "members" }]) { id: ID! messages: [Message] @connection(keyName: "messagesByConversationId", fields: ["id"]) associated: [ConvoLink] @connection(keyName: "convoLinksByConversationId", fields: ["id"]) members: [String!]! createdAt: String updatedAt: String } type Message @key(name: "messagesByConversationId", fields: ["conversationId"]) @key(name: "messagesByUserId", fields: ["userId"]) @model(subscriptions: null, queries: null) { id: ID! userId: ID! conversationId: ID! author: User @connection(fields: ["userId"]) content: String! conversation: Conversation @connection(fields: ["conversationId"]) createdAt: String updatedAt: String } type ConvoLink @key(name: "convoLinksByConversationId", fields: ["conversationId"]) @key(name: "conversationsByUserId", fields: ["userId"]) @model( mutations: { create: "createConvoLink", update: "updateConvoLink" } queries: null subscriptions: null ) { id: ID! userId: ID! conversationId: ID! user: User @connection(fields: ["userId"]) conversation: Conversation @connection(fields: ["conversationId"]) createdAt: String updatedAt: String } type Subscription { onCreateConvoLink(userId: ID): ConvoLink @aws_subscribe(mutations: ["createConvoLink"]) onCreateMessage(conversationId: ID): Message @aws_subscribe(mutations: ["createMessage"]) }
マルチユーザーチャットアプリにおけるユーザーの行動:
- ユーザーはアカウントを作成できる
- ユーザーは会話を作成できる
- ユーザーは会話でメッセージを送れる
- ユーザーは会話のリストを閲覧できる
- ユーザーはほかのユーザーとの会話を作成できる
インスタグラムクローン
type User @model(subscriptions: null) @key(fields: ["userId"]) @auth(rules: [ { allow: owner, ownerField: "userId" }, { allow: private, operations: [read] } ]) { userId: ID! posts: [Post] @connection(keyName: "postsByUserId", fields: ["userId"]) createdAt: String updatedAt: String following: [Following] @connection(keyName: "followingByUserId", fields: ["userId"]) } type Post @model @key(name: "postsByUserId", fields: ["authorId"]) @auth(rules: [ { allow: owner ownerField: "authorId" }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) { id: ID! authorId: ID! content: String! postImage: String author: User @connection(fields: ["authorId"]) comments: [Comment] @connection(keyName: "commentsByPostId", fields: ["id"]) likes: [PostLike] @connection(keyName: "postLikesByPostId", fields: ["id"]) } type Comment @model @key(name: "commentsByPostId", fields: ["postId"]) @auth(rules: [ { allow: owner, ownerField: "authorId" }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) { id: ID! postId: ID! authorId: ID! text: String! likes: [CommentLike] @connection(keyName: "commentLikesByCommentId", fields: ["id"]) author: User @connection(fields: ["authorId"]) post: Post @connection(fields: ["postId"]) } type PostLike @model @auth(rules: [ { allow: owner, ownerField: "userId" }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) @key(name: "postLikesByPostId", fields: ["postId"]) @key(name: "postLikesByUser", fields: ["userId", "createdAt"], queryField: "likesByUser") { id: ID! postId: ID! userId: ID! user: User @connection(fields: ["userId"]) post: Post @connection(fields: ["postId"]) createdAt: String! } type CommentLike @model @auth(rules: [ { allow: owner, ownerField: "userId" }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) @key(name: "commentLikesByCommentId", fields: ["commentId"]) @key(name: "commentLikesByUser", fields: ["userId", "createdAt"], queryField: "likesByUser") { id: ID! userId: ID! postId: ID! commentId: ID! user: User @connection(fields: ["userId"]) post: Post @connection(fields: ["postId"]) createdAt: String! } type Following @model @auth(rules: [ { allow: owner, ownerField: "followerId" }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) @key(name: "followingByUserId", fields: ["followerId"]) { id: ID followerId: ID! followingId: ID! follower: User @connection(fields: ["followerId"]) following: User @connection(fields: ["followingId"]) createdAt: String! }
インスタグラムクローンにおけるユーザーの行動:
- ユーザーはアカウントを作成できる
- ユーザーはポストを作成できる
- ユーザーはポストにコメントを付けれる
- ユーザーは他のユーザーをフォロー/フォロー解除できる
- ユーザーはポスト/コメントに"いいね"を付けれる
"いいね"機能の実装については、Redditクローンと同様、カスタムresolverを実装する必要があります。
詳しくはRedditクローンの章を参照。
カンファレンスアプリ
type Talk @model @auth(rules: [ { allow: groups, groups: ["Admin"] }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) { id: ID! name: String! speakerName: String! speakerBio: String! time: String timeStamp: String date: String location: String summary: String! twitter: String github: String speakerAvatar: String comments: [Comment] @connection(keyName: "commentsByTalkId", fields: ["id"]) } type Comment @model @key(name: "commentsByTalkId", fields: ["talkId"]) @auth(rules: [ { allow: owner, ownerField: "authorId" }, { allow: public, operations: [read] }, { allow: private, operations: [read] } ]) { id: ID! talkId: ID! talk: Talk @connection(fields: ["talkId"]) message: String createdAt: String authorId: ID! deviceId: ID } type Report @model @auth(rules: [ { allow: owner, operations: [create, update, delete] }, { allow: groups, groups: ["Admin"] } ]) { id: ID! commentId: ID! comment: String! talkTitle: String! deviceId: ID } type ModelCommentConnection { items: [Comment] nextToken: String } type Query { listCommentsByTalkId(talkId: ID!): ModelCommentConnection } type Subscription { onCreateCommentWithId(talkId: ID!): Comment @aws_subscribe(mutations: ["createComment"]) }
カンファレンスアプリにおけるユーザーの行動:
- ユーザーはアカウントを作成できる
- ユーザーは講演のリストを閲覧できる
- ユーザーは個々の講演を閲覧できる
- ユーザーは講演にコメントを付けれる
- (オプション)ユーザーはコメントを報告できる
なお、追加で定義しているsubscriptiononCreateCommentWithId
によって、閲覧中の講演に対するコメントのみをsubscribeすることが出来ます。
React Nativeで構築したカンファレンスアプリの例はこちら。
おわりに
以上、各アプリを題材にしたGraphQLスキーマの設計例を見てきました。
GraphQLに馴染みのないままスキーマを設計するのは難しい作業です。 今回は、習うより慣れよ、の精神で大量のスキーマ設計を読み込みました。
個人的には大いに勉強になりましたが、これがどなたかの役にも立てれば幸いです。
関連記事
参考
GraphQL Recipes (V2) - Building APIs with GraphQL Transform - DEV Community 👩💻👨💻
API (GraphQL) - Directives | Amplify Docs
dynamodb-triggers/counter.js at master · dabit3/dynamodb-triggers · GitHub