#awsxon #jawsug AWS Expert Online at JAWS-UG長野 (AppSync編) に参加しました

7/4に開催された、AWS Expert Online at JAWS-UG長野 (AppSync編) に参加しました。
AWS Expert Online at JAWS-UG長野 - connpass

会場はギークラボ長野でした。

また、資料などは後日公開されるとのことです。

 
最近AppSyncをさわっていたので、タイムリーなイベントでした。

内容も

  • いろいろな事例の紹介
  • AppSyncをさわっている中で気になったことも、気軽に質問ができた

など、とても有意義な時間でした。

 
以下、個人的なメモなどを残します。誤りがあればご指摘ください。

 

 

メモ

自分のTweetから拾ったものです。

  • AppSync APIのドキュメントは自動生成される
  • GraphQLは、FacebookからLinux傘下になった
  • 非推奨のAPI@deprecated を使って示せる
  • nullをうまく使うのがGraphQLの特徴
  • 事例
    • リアクション配信として、8,000リクエスト/秒 をさばいてる
    • Subscriptionは配信の上限がある。バッファリングをして配信してる
    • リアルタイムデータだけではなく、通常のデータもさばいている
    • 書き込み系はDynamoDB、イベントソーシングとしてElasticsearchに流して、検索はElasticsearchで行う
    • AppSyncからDynamoDBへ直接書き込みもできるが、ビジネスロジックを入れるため、Lambdaでビジネスロジックを書いて、その後にDynamoDBへ入れている
  • AppSync SDKとAmplifyの違い
    • AppSync SDKは高機能なSDKApolloのextensionみたいな扱いで、Apolloで行えるものは使える
    • Amplifyはシンプルなもの。できることは少ないけれど、AppSyncに簡単にアクセスできる。クライアントがシンプルになる。
  • デザインパターンやサンプル
    • デザインパターンなどの情報は、 AppSync Mob401 で検索すると良い
    • aws-samplesにて、 appsync で検索すると、サンプルがいろいろと出てくる

 

自分の上げた質問

2点質問を上げたので、回答とともに残しておきます。

1. DynamoDBなどのように、ユニットテストのためにAppSyncをローカルで動かすことはできるのでしょうか?

今のところ、ローカルではユニットテストができないとのことです。

 
2. Amplifyのチュートリアルを見ると、DynamoDBやCognitoを新規作成するものが多いです。ただ、既存のDynamoDBやCognitoを使いたい場合、どうするのがベターな感じでしょうか

以下のどちらかのパターンが良いとのことです。

  • amplifyのカスタムテンプレートを使う
  • amplifyを使わずに、CloudFormationを使う

 

その他

AppSyncやAmplifyについて、個人的には

あたりが来ると嬉しいなーと感じました。

 
最後になりましたが、SA塚越さんをはじめ、関係者のみなさま、ありがとうございました。

AWS AppSyncのPipeline Resolverを使って、複数のDynamoDBの値をマージして返すAPIを作成してみた

前回・前々回と、単一のデータソースから値を取得するAWS AppSync APIを作成しました。

 
ただ、単一ではなく、「複数のDynamoDBから値を取得し、その結果をマージして返す」ようなAPIが作れないかが気になりました。

調べてみたところ、通常のResolverの代わりにPipeline Resolverを使えば良さそうでした。
チュートリアル: パイプラインリゾルバー - AWS AppSync

 
公式ドキュメントによると、今回のAPIに必要なものは

  • Data source
  • Schema
  • Function
  • Pipeline Resolver
    • Before mapping template
    • After mapping template

でした。

そこで、公式チュートリアルよりももう少し単純な例を使って、理解を深めてみることにしました。

 
目次

 

環境

  • Python 3.7.3
    • graphqlclient 0.2.4
  • AWS AppSync
    • データソース:Amazon DynamoDB x 2
      • 両DynamoDBとも作成済
    • API Key 認証
    • データ取得(Query)のみ、APIとして実装

 

DynamoDB

事前に用意した2つのDynamoDBは、以下の通りです*1

Blog table

id (key) title author_id
1 ham 100
2 spam 200

 
 
Author table

Blog tableの author_id に紐づく id を持つテーブルです。

id (key) name
100 foo
200 bar

 

APIの仕様

Blog.id == "1" となるようなQueryを投げると

Blog.id Blog.title Author.id Author.name
1 ham 100 foo

が返るものとします。

 

AWS AppSync APIのひな形作成

Build from scratch より作成します。

API nameは Pipeline Resolver API とします。

 

Data sourceの作成

Data Sources のCreate Data sourceより作成します。今回は2つ作成します。

なお、作成する際 、両方ともAutomatically generate GraphQLon にすると、2つのData source分がマージされた Schema が作成されます。便利。

ただ、今回はSchemaのResolverではなく、FunctionでDatasourceから値を取得することから、自分でSchemaを作成することになります。そのため、 off でも良いです。

また、ロールは新規作成としますが、必要な権限を持ったロールがある場合はそちらを利用しても良さそうです。

 

BlogテーブルのData source
項目
Data source name Blog_DataSource
Data source type Amazon DynamoDB table
Region DynamoDB Blog のあるRegion
Table name Blog
Create or use an existing role New role

 

AuthorテーブルのData source

Data source name・Table nameを変えるくらいで、あとは同じです。

項目
Data source name Author_DataSource
Data source type Amazon DynamoDB table
Region DynamoDB Author のあるRegion
Table name Author
Create or use an existing role New role

 

Data source Schemaの作成

今回は、

  • リクエストを受け付けるQueryの型: getBlogWithAuthor
  • 戻り値の型: BlogWithAuthor

のみ定義します。

前述のとおり、データの取得はFunctionで行うため、schema { query: Query } の定義は不要です。

type BlogWithAuthor {
    id: String!
    title: String
    author_id: String
    author_name: String
}

type Query {
    getBlogWithAuthor(id: String!): BlogWithAuthor
}

 

Functionの作成

Functionを作成してからでないとPipeline Resolverで使えなかったため、まずはFunctionから作成します。

今回、Functionは2つ用意します。

  • Blogテーブルからデータを取得
    • Function名: GetBlog
  • Blogテーブルの author_id と等しい id を持つAuthor tableデータを取得し、Blog table とAuthor tableをマージ
    • Function名: GetBlogWithAuthor

 

Functionを作るには

  • Data source name
  • Function name
  • request mapping template
  • response mapping template

が必要ですので、それぞれ作成していきます。

 
なお、mapping template系の書き方については、以下が参考になりました。ありがとうございました。
AWS AppSyncのPipeline Resolverで複数データリソースを扱う場合のVTLの書き方 | Takumon Blog

 

Function: GetBlog

Data sourceとFunction nameです。

項目
Data source name Blog_DataSource
Function name GetBlog

 

request mapping template

右にあるテンプレート GetItem を使うことで、Blog tableから指定した id のデータを取得しています。

{
    "operation": "GetItem",
    "key": {
        "id": $util.dynamodb.toDynamoDBJson($ctx.args.id),
    }
}

 

response mapping template

デフォルトのままです。

## Raise a GraphQL field error in case of a datasource invocation error
#if($ctx.error)
    $util.error($ctx.error.message, $ctx.error.type)
#end
## Pass back the result from DynamoDB. **
$util.toJson($ctx.result)

 

Function: GetBlogWithAuthor

Data sourceとFunction nameです。

項目
Data source name Author_DataSource
Function name GetBlogWithAuthor

 

request mapping template

GetBlogと似ていますが、1点異なるのは、toDynamoDBJsonの引数 $ctx.prev.result.author_id です。

Function: GetBlogWithAuthorは、GetBlogの後に動作させますが、その際、GetBlogで取得した author_id を使ってAuthorテーブルからデータを取得します。

Pipeline Resolverでは、 $ctx.prev.result で、そのFunctionの直前に動作したFunctionの結果を参照できます(今回だと、GetBlog Functionの結果)。

{
    "operation": "GetItem",
    "key": {
        "id": $util.dynamodb.toDynamoDBJson($ctx.prev.result.author_id),
    }
}

 

response mapping template

GetBlogとは異なり、結果をマージする処理を追加しています。

今回は、GetBlogで取得したBlogデータに、Authorテーブルから取得したnameをマージして返します。

なお、マージする際のキー author_name は、Schemaの type BlogWithAuthor で定義した author_name と一致させます。

type BlogWithAuthor {
    ...
    author_name: String
}

 
以上より、response mapping templateはこんな感じになります。

## Raise a GraphQL field error in case of a datasource invocation error
#if($ctx.error)
    $util.error($ctx.error.message, $ctx.error.type)
#end
## Pass back the result from DynamoDB. **

## GetBlogの結果に、author_nameとして今回取得したAuthorテーブルのnameの値をマージ
$util.qr($ctx.prev.result.put("author_name", $ctx.result.name))

## マージしたデータを返す
$util.toJson($ctx.prev.result)

 

Pipeline Resolverの作成

Schemaページの右側にあるResolverのうち、Query: getBlogWithAuthorの Attach をクリックします。

Data source nameの下に Convert to pipeline resolver があるため、何も入力しない状態でそのリンクをクリックします。

すると

  • Before mapping template
  • Function
  • After mapping template

を定義できるようになります。

 

Functionの配置

以下の順番でFunctionを配置します。

  1. Before mapping template
  2. GetBlog
  3. GetBlogWithAuthor
  4. After mapping template

 

Before mapping templateの定義

Queryから呼び出される、Before mapping template を定義します。

## Query: getBlogWithAuthorから渡された `id` を `$result` 変数に入れる
#set($result = { "id": $ctx.args.id })

## JSON化して、次のFunction: GetBlog へと渡す
$util.toJson($result)

参考:Queryの内容

type Query {
    getBlogWithAuthor(id: String!): BlogWithAuthor
}

 

After mapping templateの定義

Function: GetBlogWithAuthorから呼び出される、After mapping template を定義します。

$util.toJson($ctx.result)

こちらはFunctionの値をそのままJSON化するだけです。

 

AppSync APIクライアントの作成

今回もPythonで作ります。

  • データがある場合 (Blog.id == "1")
  • データが無い場合 (Blog.id == "x")

をそれぞれ取得するようなQueryを実行します。

from graphqlclient import GraphQLClient

from secret import API_KEY, API_URL


def execute_query_api(gql_client):
    # pipeline resolverを使ったqueryを呼ぶ:データがある場合
    query = """
        query {
          getBlogWithAuthor(id: "1") {
            id
            title
            author_id
            author_name
          }
        }
    """
    result = gql_client.execute(query)
    print(result)

    # pipeline resolverを使ったqueryを呼ぶ:データがない場合
    query = """
        query {
          getBlogWithAuthor(id: "x") {
            id
            title
            author_id
            author_name
          }
        }
    """
    result = gql_client.execute(query)
    print(result)


if __name__ == '__main__':
    c = GraphQLClient(API_URL)
    c.inject_token(API_KEY, 'X-Api-Key')
    execute_query_api(c)

 

実行結果

データがある場合の結果です。期待した結果となりました。

{"data":{"getBlogWithAuthor":{"id":"1","title":"ham","author_id":"100","author_name":"foo"}}}

 
一方、データがない場合はこのようになります。

{
"data":{"getBlogWithAuthor":null},
"errors":[{"path":["getBlogWithAuthor"],
           "data":null,
           "errorType":"DynamoDB:AmazonDynamoDBException",
           "errorInfo":null,
           "locations":[{"line":3,"column":11,"sourceName":null}],
           "message":"The provided key element does not match the schema
                      (Service: AmazonDynamoDBv2; Status Code: 400;
                       Error Code: ValidationException;
                       Request ID: xxx)"}]}

 

ソースコード

Githubに上げました。 pipeline_resolver ディレクトリの中が、今回のソースコードとなります。
https://github.com/thinkAmi-sandbox/AWS_AppSync_python_client-sample

*1:あまり良いDynamoDBの構造ではないかもしれませんが、とりあえず...

Pythonで、AWS Lambda をData sourceに持つ AWS AppSync API を呼んでみた

前回、Data sourceがDynamoDBである AWS AppSync APIPythonで呼んでみました。
Pythonで、 AWS AppSyncのquery・mutation・subscriptionを試してみた - メモ的な思考的な

 
ただ、AWS AppSync のData sourceでは、DynamoDBの他

などもData sourceとして設定できます。

そこで今回は、AWS LambdaをData source にもつ AWS AppSync APIを作成し、Pythonから呼んでみることにしました。

なお、公式ドキュメントにもチュートリアルはありますが、もう少し単純なもので試してみます。
チュートリアル : AWS Lambda リゾルバー - AWS AppSync

 
また、誤りがありましたらご指摘ください。

 
目次

 

環境

  • Python 3.7.3

    • graphqlclient 0.2.4
  • AWS AppSync

    • データソース:AWS Lambda
      • LambdaはNode.jsで実装
    • API Key 認証
    • データ取得(Query)のみ、APIとして実装

 

Lambdaを作成

AppSync APIを作る前に、Data sourceとなるLambdaを作成します。

AppSync APIから呼ぶLambdaは、

  • event
    • AppSync API から渡している値
  • context
    • Lambdaの実行環境情報
  • callback
    • AppSync APIに値を返すための関数

の3つの引数を伴って呼び出されます。

今回は、eventとcontextの値をAppSync APIに戻すようなLambdaを用意します。

 
Lambdaの設定です。

項目
関数名 AppSyncDatasource
ランタイム Node.js 10.x
アクセス権限 - 実行ロール 基本的なLambdaアクセス権限で新しいロールを作成

 
実装です。

exports.handler = (event, context, callback) => {
    // callback関数を使って、レスポンスを返す
    // 今回は、eventとcontextの値を返してみる
    callback(null, {
        "field": event.field, 
        "event": JSON.stringify(event, 3),
        "context": JSON.stringify(context, 3),
    });
};

 

AppSync APIの作成

新規作成

今回は Build from scratch より作成します。

API nameは、 My AppSync Lambda App とします。

 

Data sourceの作成

設定内容は以下の通りです。

項目
Data source name LambdaSource
Data source type AWS Lambda function
Region 作成したLambdaと同じリージョン
Function ARN 作成したLambda (AppSyncDatasource)
Create or use an existing role New role

 

Schemaの作成

AppSync APIのメニュー Schema より作成します。

今回はデータ取得を試しますので、Queryだけ用意します。

type Query {
    ham(req: String!): ResponseData
    spam(req: String!): ResponseData
}

type ResponseData {
    field: String
    event: String
    context: String
}

schema {
    query: Query
}

ポイントは type ResponseData です。定義した項目

  • field
  • event
  • context

は、いずれもLambdaのcallback関数の第二引数(連想配列)のキーと同じ名称とします。

// 参考:Lambdaの該当箇所
callback(null, {
    "field": event.field, 
    "event": JSON.stringify(event, 3),
    "context": JSON.stringify(context, 3),
});

 
また、

という2つのQueryを用意し、少しだけ実装を変えてみます。

 

SchemaとResolverを紐付ける

Schemaを作成しただけではAppSync APIとLambdaは連携しません。

そこで、SchemaとResolverを紐づけし、連携できるようにします。

Schemaメニューの右にある Resolvers から、Queryの hamAttach をクリックします。

Resolverには

  • request mapping template
  • response mapping template

の2つがあるため、それぞれ設定していきます。

 

request mapping template

request mapping templateでは、AppSync APIに来たリクエストをどのようにLambdaへ流すかを指定します。

まずは Query ham を設定します。

{
  "version" : "2017-02-28",
  "operation": "Invoke",
  "payload": {
    "field": "from_ham",
    "args": $util.toJson($context.args)
  }
}

 
ポイントは payload です。

payloadに指定した連想配列のキーが、Lambdaの引数 event のプロパティ名となります。

例えば、AppSync APIのpayloadのキー field の値は、Lambda内では event.field で取得できます。

// 参考:Lambdaの該当箇所
exports.handler = (event, context, callback) => {
    callback(null, {
        "field": event.field, 
        ...

 
また、 $context.args は、AppSync API のリクエストパラメータを指します。

 
一方、Query spamの request mapping template では固定値を設定している項目 field の値だけを変えてみます。

{
  "version" : "2017-02-28",
  "operation": "Invoke",
  "payload": {
    "field": "from_spam",
    "args": $util.toJson($context.args)
  }
}

 

response mapping template

こちらは、Lambdaの戻り値を、AppSync APIへどのように渡すかを指定します。

Query ham/spam とも、Lambdaの戻り値をそのままJSONとして返すため、

$util.toJson($context.result)

とします。

これを元にして、AppSync APIのGraphQLで値を取得できます。

 
以上で、AppSync APIの設定が終わりました。

 

PythonのGraphQLクライアント作成

前回同様、 graphqlclient を使って、AppSync APIへとアクセスしてみます。

API_KEY, API_URLは、AppSync APIのSettingsにあるものを設定します。

from graphqlclient import GraphQLClient

from secret import API_KEY, API_URL


def execute_query_api(gql_client):
    # ham Queryの実行
    ham = """
        query {
          ham(req: "456") {
            field
            event
            context
          }
        }
    """
    ham_result = gql_client.execute(ham)
    print(ham_result)

    # spam Queryの実行
    spam = """
        query {
          spam(req: "789") {
            field
            event
            context
          }
        }
    """
    spam_result = gql_client.execute(spam)
    print(spam_result)


if __name__ == '__main__':
    c = GraphQLClient(API_URL)
    c.inject_token(API_KEY, 'X-Api-Key')
    execute_query_api(c)

 

実行結果

上記のPythonを実行すると以下の結果が得られました*1

Query: ham の結果

{"data":
  {"ham":{
   "field":"from_ham",
   "event":"{\"field\":\"from_ham\",\"args\":{\"req\":\"456\"}}",
   "context":"{\"callbackWaitsForEmptyEventLoop\":true,
               \"functionVersion\":\"$LATEST\",
               \"functionName\":\"AppSyncDatasource\",
               \"memoryLimitInMB\":\"128\",
               \"logGroupName\":\"/aws/lambda/AppSyncDatasource\",
               \"logStreamName\":\"2019/07/xx/[$LATEST]xxx\",
               \"invokedFunctionArn\":\"arn:aws:lambda:region:iam:function:AppSyncDatasource\",
               \"awsRequestId\":\"xx-xx-xx-xx-xx\"}"}}}

 
Query: spamの結果

{"data":
  {"spam":{
   "field":"from_spam",
   "event":"{\"field\":\"from_spam\",\"args\":{\"req\":\"789\"}}",
   "context":"{\"callbackWaitsForEmptyEventLoop\":true,
               \"functionVersion\":\"$LATEST\",
               \"functionName\":\"AppSyncDatasource\",
               \"memoryLimitInMB\":\"128\",
               \"logGroupName\":\"/aws/lambda/AppSyncDatasource\",
               \"logStreamName\":\"2019/07/xx/[$LATEST]xxx\",
               \"invokedFunctionArn\":\"arn:aws:lambda:region:iam:function:AppSyncDatasource\",
               \"awsRequestId\":\"xx-xx-xx-xx-xx\"}"}}}

 

この結果より、

Python -> AWS AppSync API -> AWS Lambda

という経路で、データを取得できているようです。

 

ソースコード

GitHubに上げました。ディレクトlambda_datasource が今回のソースコードです。
https://github.com/thinkAmi-sandbox/AWS_AppSync_python_client-sample

*1:見やすくするよう改行を入れていますが、実際は一行です

Pythonで、 AWS AppSyncのquery・mutation・subscriptionを試してみた

最近 AWS AppSync にふれる機会がありました。

そこで今回は、AWS AppSyncのGraphQLインタフェースを使って、Pythonでquery・mutation・subscriptionを試してみましたので、メモを残します。

 

目次

 

環境

 
既存のDynamoDBは

  • title (key)
  • content

という2列を持つAppSyncToDoテーブルです。

$ aws dynamodb describe-table --table-name AppSyncToDo
{
    "Table": {
        "AttributeDefinitions": [
            {
                "AttributeName": "title",
                "AttributeType": "S"
            }
        ],
        "TableName": "AppSyncToDo",
        "KeySchema": [
            {
                "AttributeName": "title",
                "KeyType": "HASH"
            }
        ],
        "TableStatus": "ACTIVE",
        "CreationDateTime": xxx,
        "ProvisionedThroughput": {
            "NumberOfDecreasesToday": 0,
            "ReadCapacityUnits": 5,
            "WriteCapacityUnits": 5
        },
        "TableSizeBytes": 49,
        "ItemCount": 2,
        "TableArn": "arn:aws:dynamodb:xxx",
        "TableId": "xxx",
        "LatestStreamLabel": "xxx",
        "LatestStreamArn": "arn:aws:dynamodb:xxx"
    }
}

 

 

長いのでまとめ

Web上ではJavaScriptのサンプルが多いですが、Pythonでも問題なく動作しました。

以降は、試した時の流れです。

 

AWS AppSyncでAPIを作る

AppSyncのコンソールに入り、 Create API します。

  • Getting Started
    • Import DynamoDB table を選択
  • Import DynamoDB Table
    • RegionとTable nameは、既存のものを選択
    • Create or use an existing roleは、 New role を選択
    • Name the model は、適当に付ける
    • Configure model fieldsは、Keyのtitleの他、 contentString で用意
    • API nameは、適当に付ける

 

すると、以下のようなSchemeが自動的にできました。

type AppSyncToDo {
    title: String!
    content: String
}

type AppSyncToDoConnection {
    items: [AppSyncToDo]
    nextToken: String
}

input CreateAppSyncToDoInput {
    title: String!
    content: String
}

input DeleteAppSyncToDoInput {
    title: String!
}

type Mutation {
    createAppSyncToDo(input: CreateAppSyncToDoInput!): AppSyncToDo
    updateAppSyncToDo(input: UpdateAppSyncToDoInput!): AppSyncToDo
    deleteAppSyncToDo(input: DeleteAppSyncToDoInput!): AppSyncToDo
}

type Query {
    getAppSyncToDo(title: String!): AppSyncToDo
    listAppSyncToDos(filter: TableAppSyncToDoFilterInput, limit: Int, nextToken: String): AppSyncToDoConnection
}

type Subscription {
    onCreateAppSyncToDo(title: String, content: String): AppSyncToDo
        @aws_subscribe(mutations: ["createAppSyncToDo"])
    onUpdateAppSyncToDo(title: String, content: String): AppSyncToDo
        @aws_subscribe(mutations: ["updateAppSyncToDo"])
    onDeleteAppSyncToDo(title: String, content: String): AppSyncToDo
        @aws_subscribe(mutations: ["deleteAppSyncToDo"])
}

input TableAppSyncToDoFilterInput {
    title: TableStringFilterInput
    content: TableStringFilterInput
}

input TableBooleanFilterInput {
    ne: Boolean
    eq: Boolean
}

input TableFloatFilterInput {
    ne: Float
    eq: Float
    le: Float
    lt: Float
    ge: Float
    gt: Float
    contains: Float
    notContains: Float
    between: [Float]
}

input TableIDFilterInput {
    ne: ID
    eq: ID
    le: ID
    lt: ID
    ge: ID
    gt: ID
    contains: ID
    notContains: ID
    between: [ID]
    beginsWith: ID
}

input TableIntFilterInput {
    ne: Int
    eq: Int
    le: Int
    lt: Int
    ge: Int
    gt: Int
    contains: Int
    notContains: Int
    between: [Int]
}

input TableStringFilterInput {
    ne: String
    eq: String
    le: String
    lt: String
    ge: String
    gt: String
    contains: String
    notContains: String
    between: [String]
    beginsWith: String
}

input UpdateAppSyncToDoInput {
    title: String!
    content: String
}

 
また、Queriesも自動生成されました。queryとmutationの2つができています。

# Click the orange "Play" button and select the createAppSyncToDo
# mutation to create an object in DynamoDB.
# If you see an error that starts with "Unable to assume role",
# wait a moment and try again.
mutation createAppSyncToDo($createappsynctodoinput: CreateAppSyncToDoInput!) {
  createAppSyncToDo(input: $createappsynctodoinput) {
    title
    content
  }
}


# After running createAppSyncToDo, try running the listAppSyncToDos query.
query listAppSyncToDos {
  listAppSyncToDos {
    items {
      title
      content
    }
  }
}

 
mutation用の QUERY VARIABLES にも、初期値が設定されています。

{
  "createappsynctodoinput": {
    "title": "Hello, world!",
    "content": "Hello, world!"
  }
}

 
試しに createAppSyncToDo を実行してみると、結果が右に表示されました。

{
  "data": {
    "createAppSyncToDo": {
      "title": "Hello, world!",
      "content": "Hello, world!"
    }
  }
}

 
DynamoDBにもデータが追加されています。

$ aws dynamodb get-item --table-name AppSyncToDo --key '{"title": {"S": "Hello, world!"}}'
{
    "Item": {
        "content": {
            "S": "Hello, world!"
        },
        "title": {
            "S": "Hello, world!"
        }
    }
}

 
これで、AppSyncとDynamoDBが連携できていることが分かりました。

 

mutationの実行

APIができたため、次はPythonのGraphQLクライアントライブラリを使って、AppSyncにmutationを投げてデータを登録してみます。

PythonのGraphQLクライアントライブラリはGraphQLサイトにまとめられているものの他、Githubで公開されているものがあります。
https://graphql.org/code/#python

今回は、READMEにAPI Keyの渡し方が書いてあった、 python-graphql-client を使って試してみます。
prisma/python-graphql-client: Simple GraphQL client for Python 2.7+

 
pip install graphqlclient でインストール後、こんな感じでmutationを実行するスクリプトを作成します。

from graphqlclient import GraphQLClient

def execute_mutation_api(gql_client, title, content):
    # AWS AppSyncのQueriesをそのまま貼って動作する
    mutation = """
        mutation createAppSyncToDo($createappsynctodoinput: CreateAppSyncToDoInput!) {
            createAppSyncToDo(input: $createappsynctodoinput) {
                title
                content
              }
            }
    """

    variables = {
        "createappsynctodoinput": {
            "title": title,
            "content": content,
        }
    }

    result = gql_client.execute(mutation, variables=variables)
    print(result)
    
    
if __name__ == '__main__':

    c = GraphQLClient(API_URL)
    c.inject_token(API_KEY, 'X-Api-Key')

    # 登録する
    execute_mutation_api(c, 'ham', 'spam')

 
execute_mutation_api 関数の mutation および variables は、Queriesに記載されている内容をそのまま貼っています。

また、 API_URLAPI_KEY については、AppSyncのSettingsに記載されている内容を使います。

 
準備ができたため、スクリプトを実行してみると、ログに以下が出力されました。

{"data":{"createAppSyncToDo":{"title":"ham","content":"spam"}}}

 
awscliで、DynamoDBの内容を確認します。

$ aws dynamodb get-item --table-name AppSyncToDo --key '{"title": {"S": "ham"}}'
{
    "Item": {
        "content": {
            "S": "spam"
        },
        "title": {
            "S": "ham"
        }
    }
}

 
データが登録されており、mutationは成功したようです。

 

queryの実行

続いて、DynamoDBのデータを query を使って取得してみます。

query内容は、AppSyncで自動的に作成された listAppSyncToDos をそのまま使います。

def execute_query_api(gql_client):
    query = """
        query listAppSyncToDos {
          listAppSyncToDos {
            items {
              title
              content
            }
          }
        }
    """
    result = gql_client.execute(query)
    print(result)
    
if __name__ == '__main__':
    c = GraphQLClient(API_URL)
    c.inject_token(API_KEY, 'X-Api-Key')

    # 登録した情報を取得する
    execute_query_api(c)

 
このスクリプトを実行してみます。

{"data":{"listAppSyncToDos":{"items":[
    {"title":"ham","content":"spam"},
    {"title":"Hello, world!","content":"Hello, world!"}
]}}}

AppSyncのコンソールから入力した内容、および、mutationで登録した内容を取得できました*1

 

subscriptionの実行

onCreate系のsubscription

最後にsubscriptionを実行してみます。

AppSyncのSettingsにはhttpsのエンドポイントはあるものの、subscriptionで使われると思われるWebSocketのエンドポイントが見当たりませんでした。

 
いろいろ試してみたところ、AppSyncでのsubscriptionの流れは

  1. httpsのエンドポイントにsubscriptionをHTTPリクエストする
  2. MQTTのエンドポイントやその他の情報が返ってくる
  3. WebSocket(wss)を使って、MQTTのエンドポイントに接続
  4. DynamoDBでイベントが発生した時に、通知を受け取る

となるようです。

 
そこで、onCreate系のsubscriptionを例に、順に試してみます。

 

httpsのエンドポイントにsubscriptionをHTTPリクエスト & レスポンス

DynamoDBで新規作成イベントが発生した場合にtitleとcontentを受け取るsubscriptionを用意します。

def execute_subscription_api(gql_client, subscription):
    # Subscription APIに投げると、MQTTの接続情報が返ってくる
    r = gql_client.execute(subscription)

    # JSON文字列なので、デシリアライズしてPythonオブジェクトにする
    response = json.loads(r)

    # 中身を見てみる
    print(response)
    

if __name__ == '__main__':
    c = GraphQLClient(API_URL)
    c.inject_token(API_KEY, 'X-Api-Key')
    
    # DynamoDBが更新された時の通知を1回だけ受け取る
    # Subscription API用のGraphQL (onCreate系)
    subscription = """
        subscription {
            onCreateAppSyncToDo {
                title
                content
            }
        }
    """
    execute_subscription_api(c, subscription)

 
実行してみると次のようなレスポンスが返ってきます。

{'extensions': 
     {'subscription':
          {
              'mqttConnections': [
                  {'url': 'wss://<host>.iot.<region>.amazonaws.com/mqtt?<v4_credential>',
                   'topics': ['path/to/onCreateAppSyncToDo/'], 
                   'client': '<client_id>'}],
              'newSubscriptions': {
                  'onCreateAppSyncToDo': 
                      {'topic': 'path/to/onCreateAppSyncToDo/', 
                       'expireTime': None}}}}, 
    'data': {'onCreateAppSyncToDo': None}}

 
キー mqttConnections の中に、MQTTのエンドポイントやtopics、Client ID が入っていました。

 

WebSocket(wss)を使って、MQTTのエンドポイントに接続

次はPythonのMQTTクライアントを使って、MQTTのエンドポイントに接続してみます。

今回は、MQTTクライアントとして paho.mqtt.python (paho-mqtt) を使います。
eclipse/paho.mqtt.python: paho.mqtt.python

 
続いて、MQTTエンドポイント接続についてです。

レスポンスの url を見ると、プロトコルwss と、セキュアなTLSによるWebSocketを使っています。また、エンドポイントはAWS IoTのようです。

TLS & AWS IoTを使うということは、接続用の証明書などを用意しないといけないのかなと思いました。
例:X.509 証明書と AWS IoT - AWS IoT

 
しかし、レスポンスのurlを見ると、以下にある AWS 署名バージョン 4 がクエリ文字列としてすでに追加されていました。
MQTT over WebSocket プロトコル - AWS IoT

また、AWS署名バージョン4が追加済の場合に、 paho-mqtt を使って AWS IoTのMQTTエンドポイントと接続している例が、以下に記載されていました。
https://github.com/eclipse/paho.mqtt.python/issues/277#issuecomment-372019123

実際に試してみたところ、たしかにAWS IoTの証明書まわりは不要でした。

 
そこで、subscription関数に、

  1. paho-mqtt を使って、MQTTエンドポイントに接続
  2. 接続できたら、レスポンスにあった topic をsubscribeする
  3. topicからメッセージが送られてきたら、メッセージ内容を出力して、接続を切断(disconnect)する

という実装を追加してみました。

def execute_subscription_api(gql_client):
    ...
    def on_connect(client, userdata, flags, respons_code):
        print('connected')
        # 接続できたのでsubscribeする
        client.subscribe(topic)

    def on_message(client, userdata, msg):
        # メッセージを表示する
        print(f'{msg.topic} {str(msg.payload)}')

        # メッセージを受信したので、今回は切断してみる
        # これがないと、再びメッセージを待ち続ける
        client.disconnect()

    # Subscribeするのに必要な情報を取得する
    client_id = response['extensions']['subscription']['mqttConnections'][0]['client']
    topic = response['extensions']['subscription']['mqttConnections'][0]['topics'][0]

    # URLはparseして、扱いやすくする
    url = response['extensions']['subscription']['mqttConnections'][0]['url']
    urlparts = urlparse(url)

    # ヘッダーとして、netloc(ネットワーク上の位置)を設定
    headers = {
        'Host': '{0:s}'.format(urlparts.netloc),
    }

    # 送信時、ClientIDを指定した上でWebSocketで送信しないと、通信できないので注意
    mqtt_client = MQTTClient(client_id=client_id, transport='websockets')

    # 接続時のコールバックメソッドを登録する
    mqtt_client.on_connect = on_connect

    # データ受信時のコールバックメソッドを登録する
    mqtt_client.on_message = on_message

    # ヘッダやパスを指定する
    mqtt_client.ws_set_options(path=f'{urlparts.path}?{urlparts.query}',
                               headers=headers)

    # TLSを有効にする
    mqtt_client.tls_set()

    # wssで接続するため、443ポートに投げる
    mqtt_client.connect(urlparts.netloc, port=443)

    # 受信するのを待つ
    mqtt_client.loop_forever()

 
ポイントは、MQTTのクライアントを生成する際

MQTTClient(client_id=client_id, transport='websockets')

と、

  • client_idに、レスポンスの client の値を指定
  • transportとして、 websockets を指定

の2つとなります。

 
次に、スクリプトを実行してみると、コンソールに connected が表示されたままになりました。うまくいっているようです。

 

DynamoDBでイベントが発生した時に、通知を受け取る

最後に、AppSyncのコンソールから以下のデータを1件登録してみます。

{
  "createappsynctodoinput": {
    "title": "new",
    "content": "new content"
  }
}

 
すると、コンソールが進み、以下のログを出して終了しました*2

path/to/onCreateAppSyncToDo/ b'{"data":{"onCreateAppSyncToDo":
    {"title":"new",
     "content":"new content",
     "__typename":"AppSyncToDo"}}}'

onCreate系のsubscriptionができているようです。

 
ちなみに、一番最初で見たとおり、DynamoDBのストリームは無効化してあります。しかし、AppSyncではDynamoDBの変更を検知し、クライアント側に通知が来ました。これは更新系・削除系でも同じでした。

 

onUpdate系のsubscription

同様にして、onUpdate系を試してみます。subscriptionはこんな感じです。

update_subscription = """
    subscription {
        onUpdateAppSyncToDo {
            title
            content
        }
    }
"""
execute_subscription_api(c, update_subscription)

 
MQTTで接続後、AppSyncコンソールのQueriesを更新系のmutationに差し替えて実行します。

mutation updateAppSyncToDo($updateappsynctodoinput: UpdateAppSyncToDoInput!) {
  updateAppSyncToDo(input: $updateappsynctodoinput) {
    title
    content
  }
}

QUERY VARIABLESも変更します。

{
  "updateappsynctodoinput": {
    "title": "new",
    "content": "update"
  }
}

 
AppSync上で上記のmutationを実行します。すると、MQTTを実行していたコンソールに以下が表示され、更新系のsubscriptionも受信できました。

path/to/onUpdateAppSyncToDo/ b'{"data":{"onUpdateAppSyncToDo":
    {"title":"new",
     "content":"update",
     "__typename":"AppSyncToDo"}}}'

 

onDelete系のsubscription

onDelete系も試してみます。Python上では以下のsubscriptionを作成します。

delete_subscription = """
    subscription {
        onDeleteAppSyncToDo {
            title
            content
        }
    }
"""
execute_subscription_api(c, delete_subscription)

MQTTで接続後、AppSyncコンソールのQueriesを削除系のmutationに差し替えて実行します。

mutation deleteAppSyncToDo($deleteappsynctodoinput: DeleteAppSyncToDoInput!) {
  deleteAppSyncToDo(input: $deleteappsynctodoinput) {
    title
    content
  }
}

QUERY VARIABLESも変更します。

{
  "deleteappsynctodoinput": {
    "title": "new"
  }
}

 
AppSync上で上記のmutationを実行します。すると、MQTTを実行していたコンソールに以下が表示され、削除系のsubscriptionも受信できました。

{
  "data": {
    "deleteAppSyncToDo": {
      "title": "new",
      "content": "update"
    }
  }
}

 
以上より、Pythonで、 AWS AppSyncのquery・mutation・subscriptionをすべて試すことができました。

 

ソースコード

GitHubにあげました。 query_mutation_subscription ディレクトリの中が今回のソースコードです。
https://github.com/thinkAmi-sandbox/AWS_AppSync_python_client-sample

 

その他

PythonのGraphQLクライアントのみでsubscription

今回、subscriptionではMQTTクライアントも併用していました。

GraphQLクライアントだけでできればいいなーとは思ったのですが、今のところ対応しているクライアントは無さそうです。

 
もしくは、WebSocketだけのクライアント実装がありました(HTTPは話せない)

 

イベント情報

来週(2019/7/4)、ぎーらぼで AWS Expert Online (AWS AppSync関連) のイベントがあります。
AWS Expert Online at JAWS-UG長野 - connpass

*1:実際には一行ですが、見やすいように改行してあります

*2:表示の都合上、途中で改行しています

SSHトンネル内にSSHトンネルを通して、踏み台2つを経由した多段ポートフォワーディングをしてみた

踏み台の奥に目的のサーバがある環境では、踏み台経由のSSHトンネルを作り、多段ポートフォワーディングにてアクセスすることがあります。

そんな中、SSHトンネル1つだけでは接続できない環境がありました。

そこで、SSHトンネル内にSSHトンネルを通して、踏み台2つを経由した多段ポートフォワーディングした時のメモを残します。

なお、もしかしたらより良い方法があるかもしれないため、ご存知の方は教えていただけるとありがたいです。

 
目次

 

環境

ネットワーク構成は以下のとおりです。

Mac -- (ssh) -> 踏み台A -- (ssh) --> 踏み台B -- (ssh) --> DBサーバ

 
各サーバの設定内容です。いずれもSSH用のポートしか外部に開放されていません。

種類 ホスト名 開放ポート SSHユーザ SSH鍵のありか サーバ設定変更可否
踏み台A Gateway 10022 (SSH) shinano_gold Mac 不可
踏み台B Bastion 20022 (SSH) shinano_dolce Mac 不可
DBサーバ DB 30022 (SSH) akibae Bastion 不可

 
この時、DBサーバの 3306 ポートを、Mac13306 ポートにつなげたいとします。

 

ダメだった接続

1本のSSHトンネルを使う場合は、以下のような方法で接続します。

 
ただ、今回の場合、サーバ Gateway で何かしらの設定がされているようでした。そのため、Mac - DBサーバ間で通信できませんでした。

一方、

Mac -- (ssh) --> 踏み台B -- (ssh) --> DBサーバ

であれば接続できたものの、踏み台Aを回避する方法はさすがにダメでした。

 

SSHトンネル内にSSHトンネルを通す接続

上記のとおり、踏み台A(Gateway)が厄介そうでした。

そこで、以下の資料を参考に、SSHトンネル内にSSHトンネルを通すことで、踏み台A(Gateway)を通過することにしました。

 

1本目のSSHトンネル

1本目は、Macから踏み台B(Bastion)へ直接SSH可能にするためのSSHトンネルを用意します。ssh/configは以下のとおりです。

Host sub
  user           shinano_gold
  HostName       Gateway
  Port           10022
  IdentityFile   /mac/path/to/gateway_key
  LocalForward   11022 Bastion:20022
  AddKeysToAgent yes
  UseKeychain    yes

ポイントはLocalForwardにある 11022 Bastion:20022 です。

MacからGatewayへポート 10022SSH接続するとともに、GatewayからBastionヘSSHするためのポート 20022Macのローカルポート 11022 を接続しています。

これにより、Macのローカルポート 11022SSHすることで、裏側ではBastionの 20222 ポートとのSSHトンネルが作成されます。

なお、SSH鍵にパスフレーズがついている場合、

  • AddKeysToAgent
  • UseKeychain

の両方を yes としておくと、AgentとKeychainが使われるため、パスフレーズの再入力が不要となります。

 
続いて、このconfigを使って、ターミナルでGatewayサーバに接続します。見た目は普通のSSHですが、ログを見るとポートフォワードも有効になっています。

$ ssh sub -v
...
debug1: Local connections to LOCALHOST:11022 forwarded to remote address Bastion:20022
debug1: Local forwarding listening on 127.0.0.1 port 11022.
...
[shinano_gold@Gateway ~]$ 

 

1本目のSSHトンネル内に通す、SSHトンネル

まずは、1本目のSSHトンネルを使って Mac -> Bastion でSSHするためのssh/configを書きます。

Macのローカルポート 11022 に、DBの3306ポートからフォワードされる予定のポート 50000 を接続しています。

Host main
  user           shinano_dolce
  HostName       127.0.0.1
  Port           11022
  IdentityFile   /mac/path/to/bastion_key
  LocalForward   13306 localhost:50000
  AddKeysToAgent yes
  UseKeychain    yes

 

続いて、Mac -> Bastion の接続を行います。

1本目のSSHトンネルのターミナルはそのままにして、別のターミナルを開いてMac上で実行します。

$ ssh main -v
...
debug1: Local connections to LOCALHOST:13306 forwarded to remote address localhost:50000
debug1: Local forwarding listening on 127.0.0.1 port 13306.
...
[shinano_dolce@Bastion ~]$ 

50000 ポートへのフォワードが成功しました。

 
続いて、Bastion -> DBの接続です。

本当は Bastion -> DB の接続も ssh/config 化したいのですが、Bastionサーバ上からSSH鍵を移動できないという制限があります。これにより、MacSSH鍵がある前提の ProxyCommand が使えないことから、ssh/config化をあきらめました*1

 
では、2本目のターミナル上にて、DBのポート 3306 を、Bastion のポート 50000 ポートへとポートフォワーディングするようSSHしてみます。

[shinano_dolce@Bastion ~]$ ssh -N -L 50000:DB:3306 akibae@DB -p 30022 -i /bastion/path/to/db_key -v
...
debug1: Local connections to LOCALHOST:50000 forwarded to remote address DB:3306
...

なお、今回は -N オプション無しだと接続が切れてしまったので、あえて付けています。
SSH (1) | OpenSSH-7.3p1 日本語マニュアルページ (2016/10/15)

 
接続できたので、動作確認です。

Mac13306 ポートにつながっていると思われるDBに、telnetで接続します*2

$ telnet localhost 13306
Trying 127.0.0.1...
Connected to localhost.
...
mysql_native_password

 
DBサーバのポート 3306 に接続でき、また、DBサーバ上で動いているMySQLと会話ができました。

*1:何か良い方法があるのかな...

*2:High Sieera以降は telnet が無いようなので、homebrew などでインストールします https://support.apple.com/ja-jp/HT208430

LaravelでCRUDのあるToDoアプリを作ってみた

Laravelを理解するために、CRUDのあるToDoアプリを作ってみました。

そこで、後で思い出しやすいよう、慣れているDjangoでの作り方も併記する形で、メモを残しておきます。

 
目次

   

環境

 

プロジェクトの作成

Djangoでは、 django-adminmanage.py を使って、Djangoプロジェクトを作成します。

# Djangoプロジェクト
$ django-admin startproject todolist .

# Djangoアプリ
$ python manage.py startapp web

 
Laravelは Composer を使って、Laravelプロジェクトを作成します。

$ composer create-project laravel/laravel todolist --prefer-dist "5.5.*"

 

モデルの作成

テーブル定義

今回のテーブル定義は以下です。

項目名 定義 内容
id 自動インクリメント 主キー
title char(50) タイトル
content text 内容
priority int 優先度
created_at 日付 作成日
updated_at 日付 更新日

 

モデルとマイグレーションファイル

Djangoでは、テーブル定義をモデルファイルに実装後、 migrate を使ってマイグレーションファイルを作成します。

一方、Laravelでは artisan (アルチザン) でモデルとマイグレーションファイルのひな形を作成します。その後、テーブル定義をマイグレーションファイルに実装します。

まずは、artisanでひな形を作成します。今回は、モデルとともにマイグレーションファイルも作成します。
https://readouble.com/laravel/5.7/ja/eloquent.html#defining-models]

php artisan make:model ToDo --migration

Model created successfully.
Created Migration: 2019_06_08_131917_create_to_dos_table

 
ひな形ができたので、次はテーブル定義をマイグレーションファイルに実装します。

今回は todolist/database/migrations/2019_06_08_131917_create_to_dos_table.php にある up() メソッドに実装します。

なお、Laravelでは、作成日/更新日のタイムスタンプ系は timestamps() を使うことで、自動で定義されます。

<?php
public function up()
{
    Schema::create('to_dos', function (Blueprint $table) {
        $table->increments('id');
        $table->timestamps();

        $table->char('title', 50);
        $table->text('content');
        $table->integer('priority');
    });
}

 
ちなみに、モデルファイル todolist/app/ToDo.php も自動で生成されています。ただし、今回実装するものはありません。

 

マイグレーションの実行

artisan を使って、データベースにモデルの内容を反映するマイグレーションを行います。

$ php artisan migrate

 

コントローラーのひな形作成

DjangoのViewに該当する、Laravelのコントローラーを作成します。

コントローラーも artisan を使ってひな形を作成します。

php artisan make:controller ToDoController --model=App\\ToDo

Controller created successfully.

 

今回のアプリのURLとコントローラーの関係

今回

  • URL
  • Laravelのコントローラーのメソッド
  • Djangoのビュー

の各関係は以下の通りです。

URL 機能 Laravelのコントローラー Djangoのビュー
/todo/ 一覧 index() ToDoListView
/todo/(pk) 詳細 show() ToDoDetailView
/todo/create 作成 [GET]create(), [POST]store() ToDoCreateView
/todo/(pk)/update 更新 [GET]edit(), [POST]update() ToDoUpdateView
/todo/(pk)/delete 削除 [GET]confirm(), [POST]destroy() ToDoDeleteView

 

routesの作成

routesでは、

  • URL
  • Laravelのコントローラーのメソッド

の紐付けを行います。

Djangourls.py にあたるものを、Laravelでは todolist/routes/web.php に実装します。

<?php

Route::prefix('todo')->group(function(){
    // 一覧
    Route::get('/', 'ToDoController@index')->name('todo.index');

    // 詳細
    Route::get('/{id}', 'ToDoController@show')
        ->where('id', '[0-9]+')
        ->name('todo.show');

    // 新規作成
    Route::get('/create', 'ToDoController@create')
        ->name('todo.create');
    Route::post('/create', 'ToDoController@store')
        ->name('todo.store');

    // 編集
    Route::get('/{id}/edit', 'ToDoController@edit')
        ->where('id', '[0-9]+')
        ->name('todo.edit');
    Route::post('/{id}/update', 'ToDoController@update')
        ->where('id', '[0-9]+')
        ->name('todo.update');

    // 削除
    Route::get('/{id}/delete', 'ToDoController@confirm')
        ->where('id', '[0-9]+')
        ->name('todo.confirm');
    Route::post('/{id}/delete', 'ToDoController@destroy')
        ->where('id', '[0-9]+')
        ->name('todo.destroy');
});

 
Django同様、Laravelでも名前付きルートの定義が name() で可能です。
https://readouble.com/laravel/5.5/ja/routing.html#named-routes

名前付きルートをDjangonamespace:name のような形にしたい場合、Laravelでは <モデル名>.<メソッド名> と表記している例があったため、それを採用しました。もし、他に正しい表記方法があれば、ご指摘ください。
https://webdevetc.com/blog/laravel-naming-conventions

 
他に、Django<int:pk> のような定義を、 where() を使って実装しています。上記例では id を数字のみに制限しています。
https://readouble.com/laravel/5.5/ja/routing.html#parameters-regular-expression-constraints

なお、グローバル制約も可能なようですが、今回は使っていません。
https://readouble.com/laravel/5.5/ja/routing.html#parameters-global-constraints

 

コントローラーの作成

artisan で作成したひな形 (todolist/app/Http/Controllers/ToDoController.php) に実装します。

 

一覧

id の降順で、 ToDo モデルの全データを表示したい場合、DjangoではViewの ordering を使いますが、Laravelでは latest() を使うのが簡単なようです。
https://readouble.com/laravel/5.5/ja/queries.html#ordering-grouping-limit-and-offset

<?php

public function index()
{
    $todos = ToDo::latest('id')->get();
    return view('todo.list', ['todos' => $todos]);
}

 

詳細

詳細は

  • データがあれば、その内容を表示
  • データがなければ、HTTP 404 を表示

とします。

Djangoでは get_object_or_404() があります。
https://docs.djangoproject.com/ja/2.2/intro/tutorial03/#a-shortcut-get-object-or-404

一方、Laravelでは firstOrFail() を使います。
https://readouble.com/laravel/5.7/ja/eloquent.html#retrieving-single-models

<?php

public function show(int $id)
{
    return view(
        'todo.detail',
        ['todo' => ToDo::where('id', $id)->firstOrFail()]
    );
}

 

バリデーション

新規登録に行く前に、バリデーションを検討します。

新規登録や更新処理では、入力値に対する検証(バリデーション)を行う必要があります。

Djangoであれば、 ModelForm を使うことで、モデルで定義した制約(文字列長や型)に対して自動的にバリデーションが行われます。

一方、Laravelではフォームとモデルでバリデーションを共有する方法は見つけられませんでした。

ただ、Laravelにもバリデーション機能があり、いくつかの実装方法がありました。

その中から今回は

  • カスタムバリデーションルール
  • フォームリクエストバリデーション

の2つを使って実装してみます。

 

カスタムバリデーションルール

Laravelでは標準でたくさんのバリデーションルールがあります。
https://readouble.com/laravel/5.5/ja/validation.html#available-validation-rules

今回は、 タイトルの英字は大文字のみ という独自のバリデーションルールを作成してみます。
https://readouble.com/laravel/5.5/ja/validation.html#custom-validation-rules

 
artisan でルールオブジェクトを作成します。

$ php artisan make:rule Uppercase

Rule created successfully.

 
生成された todolist/app/Rules/Uppercase.php を編集します。

今回は

  • passes() で、バリデーションが成功するときの内容
  • message() で、バリデーションエラーになったときのメッセージ

を実装します。

<?php

class Uppercase implements Rule
{
    public function passes($attribute, $value)
    {
        return mb_strtoupper($value) === $value;
    }

    public function message()
    {
        return 'タイトルの英字は、すべて大文字にしてください';
    }
}

 

フォームリクエストバリデーション

フォームリクエストバリデーションとは

フォームリクエストは、バリデーションロジックを含んだカスタムリクエストクラスです

https://readouble.com/laravel/5.5/ja/validation.html#form-request-validation

とのことです。

 
artisan を使って、フォームリクエストクラスを生成します。

$ php artisan make:request SaveToDo

Request created successfully.

 
生成された todolist/app/Http/Requests/SaveToDo.php を編集します。実装すべきものは

  • authorize()
    • 認証 (今回は認証なしなので、常に true を返す)
  • rules()
    • 対象の項目とバリデーション内容
  • messages()
    • バリデーションエラーとなった場合のメッセージ

です。

<?php

public function authorize()
{
    return true;
}

public function rules()
{
    return [
        'title' => ['max:50', new Uppercase],
        'priority' => ['integer'],
    ];
}

public function messages()
{
    return [
        'priority.integer' => '優先度は数字で入力してください',
    ];
}

 
ここまでで、新規登録/更新のバリデーション定義が終わりました。

 

新規登録

GET時の create() と、POST時の store() を実装します。

<?php

public function create()
{
    return view('todo.create');
}

public function store(SaveToDo $request)
{
    $target = new ToDo;
    $target->title = $request->input('title');
    $target->content = $request->input('content');
    $target->priority = $request->input('priority');
    $target->save();

    // save()後、$todoには保存したときのidがセットされる
    return redirect()->route('todo.show', ['todo' => $target]);
}

create() では、view() ヘルパ関数でビューを返すだけです。
https://readouble.com/laravel/5.5/ja/helpers.html#method-view

 
store() では色々と実装したため、ポイントをまとめておきます。

  • store()の引数の型として、バリデーションオブジェクト SaveToDo を指定
    • バリデーションエラーの場合は、 store() を処理することなく、フォームなどへ戻る
  • 入力データは、引数 $request へ格納
  • モデルの save() メソッドで、データベースへ保存

 
また、 created_atupdated_at

saveが呼び出された時にcreated_atとupdated_atタイムスタンプは自動的に設定されますので、わざわざ設定する必要はありません。

https://readouble.com/laravel/5.5/ja/eloquent.html#inserting-and-updating-models

とのことです。

 
一方、悩んだところです。

GETとPOSTでコントローラーのメソッドを同一/別々、どちらにするのがLaravelっぽいのか分からなかったことです。

$request にはHTTPリクエストメソッドがあるため、Djangoの関数ベースビューのように一つのメソッドで実装できそうでした。 https://readouble.com/laravel/5.5/ja/requests.html#request-path-and-method

ただ、WebではGETとPOSTは別メソッドなことが多かったため、今回はメソッドを分けてみました。

 

更新

GET時の edit() と、POST時の update() を実装します。

今まで出てきた内容ですので、詳細は省略します。

<?php

public function edit(int $id)
{
    return view(
        'todo.edit',
        ['todo' => ToDo::where('id', $id)->firstOrFail()]
    );
}

public function update(SaveToDo $request, int $id)
{
    $target = ToDo::where('id', $id)->firstOrFail();
    $target->title = $request->input('title');
    $target->content = $request->input('content');
    $target->priority = $request->input('priority');
    $target->save();

    return redirect()->route('todo.show', ['todo' => $id]);
}

 

削除

DjangoDeleteView では確認画面がありました。

一方、Laravelの artisan のひな形には、確認画面用のメソッドは生成されませんでした。

そのため、コントローラーに confirm() メソッドを追加して、GETで表示する確認画面を追加してみました。

また、モデルの削除では、今回使った delete() の他に destory() もあるようです。
https://readouble.com/laravel/5.5/ja/eloquent.html#deleting-models

<?php

public function confirm(int $id)
{
    return view(
        'todo.confirm_delete',
        ['todo' => ToDo::where('id', $id)->firstOrFail()]
    );
}

public function destroy(int $id)
{
    $target = ToDo::where('id', $id)->firstOrFail();
    $target->delete();

    return redirect()->route('todo.index');
}

 
以上でコントローラーの実装が終わりました。

 

ビューの作成

Laravelでは、テンプレートエンジンとして Blade を使います。 https://readouble.com/laravel/5.5/ja/blade.html

Djangoと同じように、Bladeテンプレートではテンプレート継承が使えるので、今回試してみます。
https://readouble.com/laravel/5.5/ja/blade.html#template-inheritance

 

継承元テンプレート

Django{% block %} と同じような仕組みとして、Laravelでは @yield があります。

また、CSSなどを参照する場合、Djangoでは {% static %} を使いましたが、Laravelでは asset() ヘルパ関数を使います。
https://readouble.com/laravel/5.5/ja/helpers.html#method-asset

あとは、DjangoLANGUAGE_CODE の代わりに、Config::get() を使いました。

全体像は以下の通りです。

<!DOCTYPE html>

<html lang="{{ Config::get('app.locale') }}">
<head>
    <meta charset="UTF-8">
    <title>
        @yield('title')
    </title>
    <link rel="stylesheet" href="{{ asset('/css/todo.css') }}">
</head>

<body>
<main class="container">
    @yield('content')
</main>
</body>
</html>

 

一覧ビュー

todolist/resources/views/todo/list.blade.php を作成します。

@extends('layouts.todo')

@section('title')
    ToDo一覧
@endsection

@section('content')

<h3>ToDo一覧</h3>
<a href="{{ route('todo.create') }}">作成</a>
<table>
    <thead>
    <tr>
        <th>ID</th>
        <th>タイトル</th>
        <th>優先度</th>
        <th>操作</th>
    </tr>
    </thead>
    <tbody>

    @foreach($todos as $todo)
    <tr>
        <td><a href="{{ route('todo.show', ['id' => $todo->id]) }}">{{ $todo->id }}</a></td>
        <td><a href="{{ route('todo.show', ['id' => $todo->id]) }}">{{ $todo->title }}</a></td>
        <td><a href="{{ route('todo.show', ['id' => $todo->id]) }}">{{ $todo->priority }}</a></td>
        <td>
            <a href="{{ route('todo.edit', ['id' => $todo->id]) }}">変更</a>
            <a href="{{ route('todo.confirm', ['id' => $todo->id]) }}">削除</a>
        </td>
    </tr>
    @endforeach

    </tbody>
</table>

@endsection

 

詳細ビュー

todolist/resources/views/todo/detail.blade.php を作成します。

@extends('layouts.todo')

@section('title')
    ToDo詳細
@endsection

@section('content')

    <h3>ToDo詳細</h3>

    <table>
        <thead>
        <tr>
            <th>ID</th>
            <th>タイトル</th>
            <th>優先度</th>
            <th>内容</th>
        </tr>
        </thead>
        <tbody>
        <tr>
            <td>{{ $todo->id }}</td>
            <td>{{ $todo->title }}</td>
            <td>{{ $todo->priority }}</td>
            <td>{{ $todo->content }}</td>
        </tr>
        </tbody>
    </table>

<a href="{{ route('todo.index') }} ">一覧へ</a>

@endsection

 

新規登録ビュー

todolist/resources/views/todo/create.blade.php を作成します。

 

LaravelCollective/htmlのインストール

新規登録ではフォームを使います。

Djangoで簡単にフォームを作る場合、 Form.as_table などを使います。

一方、Laravelでは、コアにフォーム機能が含まれていないようです。
https://stackoverflow.com/questions/35695949/why-are-form-and-html-helpers-deprecated-in-laravel-5-x

 
そのため、フォームは自分で作成するか、別途ライブラリを入れるかします。

今回は、Laravel5になった時にコミュニティ管理となった LaravelCollective/html を使います。
https://github.com/LaravelCollective/html

 
まずは Composer でインストールします。

なお、今回 Laravel5.5系を使っているため、バージョンを指定しないとインストールエラーになります。

$ composer require laravelcollective/html
...
Your requirements could not be resolved to an installable set of packages.

  Problem 1
    - Installation request for laravelcollective/html ^5.8 -> satisfiable by laravelcollective/html[v5.8.0].
    - Conclusion: remove laravel/framework v5.5.45
    - Conclusion: don't install laravel/framework v5.5.45
...
    - Installation request for laravel/framework (locked at v5.5.45, required as 5.5.*) -> satisfiable by laravel/framework[v5.5.45].

 
そのため、バージョンを指定してインストールします。

$ composer require "laravelcollective/html":"^5.5.0"

 

LaravelCollective/htmlを使ったビューの作成

LaravelCollective/html の公式サイトがメンテナナス中っぽいので、GitHubにあるドキュメントを参考に実装します。

全体像です。

@extends('layouts.todo')

@section('title')
    ToDo
@endsection


@section('content')
<h3>ToDo</h3>

{!! Form::open(['route' => 'todo.store']) !!}
    {!! Form::label('title', 'タイトル') !!}
    {!! Form::text('title') !!}
    <p>{{ $errors->first('title') }}</p>

    {!! Form::label('content', '内容') !!}
    {!! Form::textarea('content') !!}
    <p>{{ $errors->first('content') }}</p>

    {!! Form::label('priority', '優先度') !!}
    {!! Form::text('priority') !!}
    <p>{{ $errors->first('priority') }}</p>

    {!! Form::submit('保存') !!}

{!! Form::close() !!}

<a href="{{ route('todo.index') }}">一覧へ</a>

@endsection

 

更新ビュー

todolist/resources/views/todo/edit.blade.php を作成します。

更新ビューも LaravelCollective/html を使って実装します。

また、データベースの値を画面に表示するため、 LaravelCollective/html のフォームモデルバインディングを使います。
https://github.com/LaravelCollective/docs/blob/5.6/html.md#form-model-binding

フォームモデルバインディングは、

{!! Form::model($todo, ['route' => ['todo.update', $todo->id]]) !!}

と、 Form::open() の代わりに Form::model() を使います。

残りの部分は新規登録と同じなため、コードは省略します。

 

削除ビュー

todolist/resources/views/todo/confirm_delete.blade.php を作成します。

今回は、詳細ビューに

<form method="post">
    {{ csrf_field() }}

    <button type="submit">削除</button>
</form>

と、 CSRF対策の csrf_field() を持つフォームを追加するだけにしました。
https://readouble.com/laravel/5.5/ja/csrf.html

 

静的ファイル

ビューの layouts/todo.blade.php にて、

<link rel="stylesheet" href="{{ asset('/css/todo.css') }}">

CSSファイルを参照していました。

Laravelでは、CSSなどの静的ファイルは

  • public
  • resources/assets

のどちらかに入れます。
https://readouble.com/laravel/5.5/ja/structure.html#the-public-directory

使い分けは、公式ドキュメントに

publicディレクトリ publicディレクトリには、アプリケーションへの全リクエストの入り口となり、オートローディングを設定するindex.phpファイルがあります。また、このディレクトリにはアセット(画像、JavaScriptCSSなど)を配置します。

resourcesディレクトリ resourcesディレクトリはビューやアセットの元ファイル(LESS、SASS、JavaScript)で構成されています。また、すべての言語ファイルも配置します。

https://readouble.com/laravel/5.5/ja/structure.html#the-public-directory

とありました。

そのため、今回は todolist/public/css/todo.css としてCSSファイルを作成します。

th {
    font-weight: bold;
}

table {
    border: solid 1px;
    border-collapse: collapse;
}

td, th {
    border: solid 1px;
}

 

以上で、ToDoアプリの実装が終わりました。

 

起動

artisan で起動します。

以下は localhost:8001 でアクセスできるようにしています。

$ php artisan serve --host=localhost --port=8001

 

ソースコード

GitHubに上げました。

なお、実際に動かす場合は、 Docker Compose で MySQL を起動する必要があります。

#gdg信州 #io19jp Google I/O Extended 2019 in Shinshuに参加しました

6/1に開催されたGoogle I/O Extended2019 in Shinshuに参加しました。

 
会場は、県立長野図書館にある 信州・学び創造ラボでした。

 
当日の様子はTogetterにまとめられています。
Google I/O extended 2019 in Shinshuまとめ - Togetter

 
ここでは気になった内容をメモして残しておきます。

 
目次

 

メインセッション

Google I/O 2019 Overview/自動車関連

Shoko Okochiさん

1つ目の資料:Overview/Keynote : Google I/O 2019_okohs - Google スライド  
最初の発表では、今年のGoogle I/Oの様子が伝わってきました。今年のお祭りっぽい雰囲気が伝わってきてよかったです。

印象に残った内容です。

  • 今年はGoogleアシスタントが熱かった
  • MLの顔認識、前年比18倍
  • ML Kitはモバイル向け、マイコンでも動くTensorflow Lite
  • AIを使って、衛星写真から地形解析、災害が起きそうな部分をわかるようにする
  • ダークテーマ
  • オーガナイザーたくさん

 

 
2つ目の資料: What’s new in Automotive - Google スライド

2つ目の発表は、自動車関連についてのお話でした。

Googleの自動車関連の話はあまり聞いたことがなかったので、楽しく聞けました。現在のGoogleの自動車まわりがどうなっているかがまとめられていて、未来感あるお話でした。

また、Google I/Oの会場での体験動画も紹介されました。どんなものか実際に見ないと実感がわかないため、動画は嬉しかったです。

印象に残った内容です。

  • 画像データとGPSデータから、今どこを走ってるのかを把握
  • Android Automotive OS。一部Android、別のOSも載ってる
    • 連携して動いてるのかは気になる
  • Android StudioAndroid Autoアプリが簡単に作れる

 

Overview of Google I/O 2019 for Android

あんざいゆきさん

資料:Overview of Google I/O 2019 for Android - Speaker Deck

最近Androidアプリは開発していないので、どんな流れになっているのかをざっとつかめたセッションでした。

印象に残った内容です。

 

パネルディスカッション

今年もパネルディスカッションがありました。ゆるい雰囲気で質問と回答が出ていました。

個人的には、Kindleの読み上げをクルマで使ってるという会場からの情報が参考になりました。

 

LT

共に知り、共に作る場所として

信州・学び創造ラボ(県立図書館) さん

会場が

  • 昔の閉架本が公開されている
  • 3DプリンターやUVプリンター、レーザーカッターなどが置いてある

など、図書館の雰囲気があまりないままだったので、興味を持って聞けました。

会場にあった各種工作機械については、アンカンファレンスで使い方を皆で相談して進めているようです。

来週6/9にもアンカンファレンスがあり、気になる方はぜひ参加してほしいとのことでした。
(10) 信州・学び創造ラボ/アンカンファレンス#04

 

Googleの知らない自作キーボードの世界

swan_matchさん

自作キーボードの世界についてのお話でした。

技術書典で自作キーボードは見かけた上、地方でも開催するイベントが一時間で埋まってしまうなど、流行しているのが分かりました。

自作キーボードに関する知識を深められてよかったです。

 
このセッションとは別に、会場には自作キーボードが展示されていて、触れることができたり、各種パーツの説明を受けることができました。

キーボードはさわってみないと分からないことが多いため、実際に触れることができたのは良かったです。

今回展示されていたキーボードも

  • キーの配置
  • 機能
  • タッチ具合

など、各個性がはっきりしていました。

はんだ付けという個人的最難関*3がありますが、自作してみるのも面白そうだと感じました。

 

Glideappsで5分で作るPWAアプリ

kobayutaponさん

今回のイベントでも使った、Google SpreadsheetをPWAアプリ化するGlideappsの紹介でした。

3分間クッキング的な進め方で、LTの時間内にGoogle Spreadsheetをフォーム化するまでのデモが行われました。

Google Spreadsheetの計算結果を表示できたりするなど、今回の出欠確認を始めとした簡単な用途で使うのには良さそうだなーと感じました。

 

ガジェット品評会

今回も多くのガジェットが展示されていました。

IchigoJamを始めとした小さなボードも多く展示されていました。

 
その隣の島には、昔のマシンが展示されていました。その差がおもしろかったです。

 

懇親会

県立図書館とは別会場にて懇親会を行いました。

自作キーボードの話や近況などをいろいろと聞けて楽しかったです。

また、本編でタイミングを逃して質問できなかった内容を懇親会で行いましたので、ここにメモしておきます*4

前述の通り、最近Androidアプリを作りたいのですが、作る手段はいろいろあってどれにしようか悩んでいました。

あんざいさんに

  • 個人で使う、非公開のAndroidアプリ
  • iOSでは使わない

という前提で質問したところ、Kotlin をおすすめされました。

また、 Kotlin in Action という本もおすすめされたため、後日手に入れました。

これで先に進められそうです。あんざいさん、ありがとうございました。

 
最後になりましたが、Google I/O Extended 2019 in Shinshuを開催・運営・参加されたみなさま、本当にありがとうございました。

*1:とはいえ、まだリンクは消せない模様

*2:他のJetBrainse系のIDEでも使えるのかが気になる

*3:過去何回やっても全滅してる

*4:質問はパブリックなみんなが聞いている場所ですべきという話は理解してるので、これでお許しを...