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アプリ
- イベントアプリ
- チャットアプリ
- Eコマースアプリ
- WhatsAppクローン
- Redditクローン
- マルチユーザーチャットアプリ
- インスタグラムクローン
- カンファレンスアプリ
スキーマ設計例
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に馴染みのないままスキーマを設計するのは難しい作業です。
今回は、習うより慣れよ、の精神で大量のスキーマ設計を読み込みました。
個人的には大いに勉強になりましたが、これがどなたかの役にも立てれば幸いです。
関連記事
www.bioerrorlog.work
www.bioerrorlog.work
参考
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
GitHub - full-stack-serverless/conference-app-in-a-box: Full stack & cross platform app customizable & themeable for any event or conference.