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:質問はパブリックなみんなが聞いている場所ですべきという話は理解してるので、これでお許しを...

DjangoCongress JP 2019で発表するまでの準備を振り返ってみた #djangocongress

前回書いた通り、DjangoCongress JP 2019にて発表しました。
DjangoCongress JP 2019

 
去年も同じようなことを書いたのが、当時の様子を振り返るのに役立ったため、今年も残しておきます。

なお、CfPを出した時点の、自分のスペックは以下の通りです。あまり去年と変わってないです。

  • Blog書いているので、文章を書くのは苦にならない
  • 人前で話すのは苦手、特にアドリブがダメ
  • 勉強会でのLTは経験あるが、毎回やっても緊張が解けない
  • 45分のトークは、1年ぶり

 
目次

 

CfP

次のようなCfPを提出しました。

対象レベル:
初級〜

対象者:
Djangoでメールを送信したい方
Djangoでのメールテストにお困りの方

詳細:
以前に比べて各種SNSが利用される世の中になってきていますが、まだまだメールは健在です。

Djangoでは標準機能として、メールの送信が用意されています。そのため、settings.pyに設定を行うことで簡単にメールを送信できます。

一方、設定が漏れるとメールが送信できないなどの問題が発生します。

また、テスト環境でメールを送信する場合も、本番環境の設定をそのまま使うと、テストメールの誤配信が発生するおそれがあります。

この発表ではDjangoが標準で用意しているメールまわりの機能をお伝えし、標準で用意されている機能の再発明を防ぎ、開発時にメールまわりで困らなくなることを目指します。

話すこと:

- Djangoのメール機能
  - send_mail()関数やEmailMessageオブジェクトなど
- Djangoのメールバックエンドを自作する方法
  - 例:console.Backendを拡張し、MIMEエンコードされないSubjectを追加で出力する
- Djangoのメール機能を使った時のテストコード
  - django.core.mail.outboxを使った確認
- 管理者用設定でのエラー通知
  - ADMINSやMANAGERSなどを使った通知機能について
  

話さないこと:
- SendGridやAWS SESなどのメールサービスを使う場合の詳細やTips

 
3/4に投稿し、3/8に採用のメールをいただきました。

 

発表方法の調査

去年は Marp というツールで書きました。

今年も使いたいなと思い Marp のサイトを見たところ、

とのことでした。

とはいえ、本番まであまり時間がなく、何か詰まったら危ない感あったので、今回もMarpで書くことにしました。

 

プレゼン小道具の準備

スライド切替器は、昨年同様 R400t を使いました。念のため、予備の単4電池 x 2本も持参しました。

時間配分表も用意しました。カウントダウン・カウントアップの両バージョンです。

ただ、後述の通り、時間配分感覚を前日夕方までつかめていなかったため、手書きで用意するハメになりました。

f:id:thinkAmi:20190521092447p:plain

 

スライド作成・プレゼン練習

流れ

去年の経験より、プレゼンの練習期間として最低1週間は確保する必要がありました。

そのため、GW明けの完成を目指して作業を進めました。

今回、元となる自分のBlog記事がありませんでした。スライドを書きながらネタ出しすることも考えましたが、

  • 最初にネタをすべて出す
  • 後から削る

とした方が楽そうでした。そこで、ネタとなる機能をすべて実装したアプリを作ることから始めました。

その結果

  • GW:だいたいの実装を終える
  • 5/7頃:スライド初版完成

となりました。ただ、この時点のスライドはBlogの延長のようなもので、発表に耐えられるものではありませんでした。

 
次のマイルストーンは、5/14の社内プレゼン練習です。

初版からある程度削った状態でしたが、47分半くらいかかってしまいました。

ありがたいことに、練習後同僚にスライドを1枚ずつじっくり見てもらえました。中でも、「メールの文字コードについては、今はそんなに問題にならないだろう」と判断できたことが大きかったです。

これにより、文字コード部分はAppendixへ移動でき、何とか5分くらい削減できました。

 
5/16に2度目の練習があり、そこでようやく「あ、いける」という感触をつかめました。

 

プレゼン練習で注意したこと

過去の経験から、自分はアドリブ要素が増えるとプレゼンが不安定になると感じていました。

そのため、プレゼン練習もなるべく本番と同じ環境になることを目指しました。

具体的には

  • スクリーンと演台、発表位置を本番に合わせる
    • スクリーンの向かって右側に立つ、等
  • マイクを持つ
    • 後半になるにつれ、マイクが口から離れていくことが分かった

としました。

 
また、練習では同僚の時間をいただくので、有意義な時間になるよう「自分では気づきにくいこと」を見てほしい点として事前に伝えました。

こんな感じです。

  • 分かりづらい表現があるか
  • 説明に過不足があるか
  • 図解してほしい部分はあるか
  • 発表者のしぐさで、気になる部分はあるか
  • 眠くなったのはどこか

このおかげか、ありがたいフィードバックをいくつもいただけました。

 

前日

当日余裕がなくなりそうなので、有給とって前日入りしました。

また、去年は

書籍「理系のための口頭発表術」に、「発表練習は前日夜にはやらない」と書かれていたため、前日朝を最後に練習はやめました。

でしたが、時間配分の把握がイマイチできていなかったため、夕方に1度行いました。

 
夜もどうしようかなーと思っていたところに、別のやることができたため、追加の練習は行いませんでした。

別のことで気分転換できたこともあり、ゆっくり休めました。

 

当日

朝会場近くにいるし時間には間に合うだろうと思いましたが、進む方向を間違えました...。前日のうちに、一度下見をしておくべきでした。

 
幸い大事には至らず、無事に会場入りできました。

 
会場入り後も相変わらず緊張感あったので、お昼休みは会場で

  • ゼリー飲料 2個
  • ベーグル 1個

を食べて過ごしました。

ただ、ゼリー飲料2個はちょっと多かったようで、発表直前までお腹が重くてつらくなることに...

あとは、 @moon_in_nagano にうながされて、緊張をほぐすために会場をうろうろしてました。

 
発表は時間配分表を見ながら進め、3分くらい残して終わったようです。

発表中の様子はどんな感じだったのか覚えてないですが、前半早口・後半持ち直したようです。

発表が終わってからは、しばらく出がらし状態でした。

 

まとめ

自分向けにまとめておきます。

  • 前回準備したことを残しておくと、振り返るのが楽
    • 前回の反省を活かしやすく、経験値もたまりやすい
    • 場数を踏むことで、苦手意識を少しずつ減らせる
  • スライド作成は1週間前までに終わらないと、発表練習の余裕がなくなる
  • 発表練習は、他の人に聴いていただけるとより効果が大きい
  • 前日入りしたら、会場までのルートを把握しておく

 
去年・今年の経験より、発表すると得るものが多いことから、苦手だからと逃げるのはもったいないと感じています。

そのため、機会をいただけたら発表できるよう、今後も取り組んでいきたいと思います。

最後になりましたが、スタッフの方々や会場提供のサイボウズさんをはじめ、いろいろとありがとうございました。

DjangoCongress JP 2019 に参加 & 発表しました #djangocongress

5/18に開催された、DjangoCongress JP 2019に参加 & 発表しました。
DjangoCongress JP 2019

昨年同様、開催場所はサイボウズ株式会社の東京オフィスでした。
東京オフィス アクセスマップ | サイボウズ株式会社

今年も良い感じの雰囲気でした。

f:id:thinkAmi:20190521092444p:plain

 
自分の発表、および全体を通してのメモを残します。誤りがありましたらご指摘ください。

目次  

 

自分の発表「Djangoでのメール送信 - 設定からテストまで」

スライドです。

 
また、発表で使用したソースコードはこちらになります。
https://github.com/thinkAmi/DjangoCongress_JP_2019_talk

ソースコードの動作確認バージョンは

です。

 
参考にした公式ドキュメントは以下の通りです。

 
Djangoのメール送信機能についてまとめたいなー」と思ったのが、CfPを出した動機です。

そのため、発表はメール送信の仕組みに特化することにして、実際の運用やベストプラクティスについてはふれないようにしました。

そのあたりは、ありがたいことにツイートしてくださいましたので、引用します。他にも情報がありましたら教えていただけるとありがたいです。

 
というところで、以降は当日のメモです。

 

Djangoで静的ファイルとうまくやる話

tell-kさん

資料:Djangoで静的ファイルとうまくやる

静的ファイルの扱いについて、いろんな観点からの発表でした。

特に

  • STATIC_MEDIA_ の違いと、考慮すべき点
  • 配信サーバ構成ごとの扱い
  • 認証付静的ファイルの配信

のあたりが参考になりました。

 

現場で使える Django のセキュリティ対策

Akihito Yokoseさん

資料:現場で使える Django のセキュリティ対策 / Django security measures for business (DjangoCon JP 2019) - Speaker Deck

IPAの資料をベースに、Djangoのセキュリティはどうなっているかのセッションでした。

特に、

  • IPA冊子「安全なウェブサイトの作り方」の要約
  • 代表的な脆弱性とその対策
    • Djangoではどのように実装されているか
    • 不足しているところはどのように補えばよいか

など、セキュリティまわりが一通りまとまっていて、ありがたいです。

そのため、「Djangoのセキュリティは?」と質問された時は「これをどうぞ」と言えそうです。

 

Djangoアプリのデプロイに関するプラクティス

Masashi Shibataさん

資料:Djangoアプリのデプロイに関するプラクティス / Deploy django application

Djangoをデプロイするにあたり、用意すべきもの・考慮すべきものがまとまっていました。

ゼロから環境構築をしなくてはいけないときは、これを見つつ、抜けやモレがないか確認しようと感じました。

また、MySQLのBlackholeストレージエンジンの存在を知ることができたり、「個人開発する時は、Google App Engineを使うことが多い」など、参考になるお話もありました。

他に、セキュリティ攻撃を試せる環境も用意されていて、akiyokoさんのセッションと合わせると理解を深めやすいと感じました。

 

Authorization in Django

Hiroki Kiyoharaさん

資料:Authz in Django / GitPitch Slide Deck

以前、 django-keeper をちょっとさわったことがあり、Modelなしでも認可制御ができるところが良かったです。

どんな時に使うのが良いのかなと思いましたが、今回のセッションでいくつかユースケースが述べられていました。

そのため、もし適切なサイズのアプリであれば、利用しても大丈夫そうという気持ちになりました。

 

DjangoによるWebエンジニア育成への道

Yuichi Nakazawaさん *1

資料:DjangoによるWebエンジニア育成への道 - Speaker Deck

afterの終わりあたりに入社したので、へーしゃの歴史が学べてよかったです。

 

LT

サイボウズさん

クイズ形式で、サイボウズさん・PythonDjangoの歴史などが学べました。

 

Django Girls

Django Girlsの紹介でした。

  • 参加者からメンターになられた方の話
  • 参加者のインタビュー

などを聞き、良い雰囲気のコミュニティであることが伝わってくるLTでした。

 

なぜ私はプロポーザルに落ちたのか

moon_in_naganoさん

イベント内であのLTを仕上げるのは、さすがだな & 真似できないなと思いました*2

 

Django Queryset アレパターン

CardinalXaroさん

regist_dt など、細かなところにまで闇っぽい感があるLTでした。

 

Staffやってみて

shimakaze_softさん

思い切ってStaffをやるという行動力はすごいと思いました。

 

パーティ

今回から開催されたパーティですが、とても楽しかったです。

Web上でお世話になってる方に直接ご挨拶したりするなど、交流を深められました。

Djangoという共通の話題があったこともあり、パーティでも話しやすかったです。

 

謝辞

最後に謝辞です。

今年も弊社 (日本システム技研) のみなさんには、プレゼンの練習に付き合っていただき、ありがとうございました。

練習をする中でいろいろと気づき、修正できたのは大きかったです。

 
また、今年も会場の提供をしてくださったサイボウズ株式会社さま、ありがとうございました。Room2のバーっぽい雰囲気が相変わらず良かったです。

 
最後になりましたが、DjangoCongress JP 2019のスタッフ、発表者、参加者のみなさま、ありがとうございました。

今年も楽しい時間を過ごせました!

*1:同じ会社ですが、コミュニティイベントなので、 「さん」付けにします

*2:関係者なのでこのくらいで...

Windows10 + pyinstallerで、Djangoをexe化して配布可能にしてみた

最近、Windows10 + pyinstallerで、

をしました。

pyinstallerのWikiに方法が記載されていますが、いろいろとハマったため、メモを残します。
Recipe Executable From Django · pyinstaller/pyinstaller Wiki · GitHub

 
目次

 

環境

 
なお、最新のPythonは3.7.3です。

ただ、pyinstallerでexe化する際、Python3.7ではpyinstallerにパッチを当てる必要があります。

 
現時点では上記のPRがマージされていないため、今回はPython3.6.8を使いました。

 

Congratulations!を表示するDjangoアプリのexe化

まずは、Congratulations!を表示するDjangoアプリをexe化します。

Python3.6.8をダウンロードします。
Python Release Python 3.6.8 | Python.org

 
次に、Windowsのpyランチャーを使い、仮想環境を作成します。
Pythonの実行方法 - python.jp

# pyランチャーで仮想環境作成
>py -3.6 -m venv env36

 
仮想環境を有効化し、 djangopyinstaller を pip install します。

# 仮想環境を有効化
>env36\Scripts\activate

# インストール
(env36)>pip install django pyinstaller

 
Djangoプロジェクトを作成します。

(env36)>django-admin startproject myproject

 
開発サーバを起動し、Congratulations! が表示されることを確認します。

(env36)>python myprojct\manage.py runserver

 
続いて、pyinstallerを使い、exe化します。

現在のディレクトリ構成です。

(root)
+---env36
+---myproject
|   +---myapp
|   +---myproject

 
上記の (root) ディレクトリでpyinstallerを実行します。

exe化では、manage.pyを実行できるように myproject/manage.py を指定します。

また、出力はexeファイル1つにしたいため、オプション onefile (もしくは F ) を指定します。

他に、exeファイルの名前は name オプションで指定します。今回は myproject.exe です。

(env36)>pyinstaller --name=myproject myproject/manage.py --onefile

 
実行すると、rootの下に、 builddist の2つができます。

今回の myprojct.exedist の中に入ります。

また、rootディレクトリの中に、 myproject.spec ファイルが自動生成されます。

これが今回exe化した時の設定内容になります。exe化する時の設定を変更する場合は、このファイルを修正します。

 
続いて、runserverすると、Congratulations!が表示されます。

(env36)>dist\myproject.exe runserver

f:id:thinkAmi:20190421182558j:plain:w450

 

HttpResposeを返すDjangoアプリをexe化

続いて、自作のDjangoアプリをexe化してみます。

まずはHttpResponseを返すViewのDjangoアプリを作成します。

(env36)>myproject\manage.py startapp myapp

 
続いて、HttpResponseを返すDjangoアプリを作成します。

settings.py

INSTALLED_APPS = [
    'myapp.apps.MyappConfig',  # 追加
    'django.contrib.admin',
    ...
]

 
myapp.views.py

from django.http import HttpResponse


def hello(request):
    return HttpResponse('Hello, world')

 
myproject/urls.py_

from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('myapp.urls')),  # 追加
]

 
myapp/urls.py

from django.urls import path

from myapp import views

urlpatterns = [
    path('', views.hello, name='hello'),
]

 
続いて、pyinstallerを実行します。

Congratulations!のときと異なり、自動生成された myprojct.spec ファイルを使ってexeを作成します。

なお、 myproject/manage.py を指定すると、上記で変更した myproject.spec の内容が修正されてしまうことに注意します。

(env36)>pyinstaller myproject.spec --onefile

 
ただ、現時点では runserver するといくつかエラーが出ます。

対応方法としては、

  • exe化する
  • runserverして、エラーを出す
  • myproject.specで hiddenimports を修正する

を繰り返すしか方法がないようです。
Pyinstaller によるPython 3.6スクリプトのexeファイル化 - Qiita

 
そのため、今回対応した内容を残しておきます。

初回の runserver のエラーです。

(env36)>dist\myproject.exe runserver
Watching for file changes with StatReloader
Exception in thread Thread-1:
Traceback (most recent call last):
...
  File "lib\site-packages\django\apps\registry.py", line 91, in populate
  File "lib\site-packages\django\apps\config.py", line 116, in create
  File "importlib\__init__.py", line 126, in import_module
  File "<frozen importlib._bootstrap>", line 994, in _gcd_import
  File "<frozen importlib._bootstrap>", line 971, in _find_and_load
  File "<frozen importlib._bootstrap>", line 941, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "<frozen importlib._bootstrap>", line 994, in _gcd_import
  File "<frozen importlib._bootstrap>", line 971, in _find_and_load
  File "<frozen importlib._bootstrap>", line 953, in _find_and_load_unlocked
ModuleNotFoundError: No module named 'myapp'

 
そこで、 myapphiddenimports に追加します。

hiddenimports=[
    'myapp',
],

 
2回目は、 myapp.apps でエラーになったため、 myapp.apps を追加します。

(env36)>dist\myproject.exe runserver
Watching for file changes with StatReloader
Exception in thread Thread-1:
Traceback (most recent call last):
...
  File "lib\site-packages\django\apps\registry.py", line 91, in populate
  File "lib\site-packages\django\apps\config.py", line 116, in create
  File "importlib\__init__.py", line 126, in import_module
  File "<frozen importlib._bootstrap>", line 994, in _gcd_import
  File "<frozen importlib._bootstrap>", line 971, in _find_and_load
  File "<frozen importlib._bootstrap>", line 953, in _find_and_load_unlocked
ModuleNotFoundError: No module named 'myapp.apps'

 
3回目は、 myapp.urls のエラーです。これも追加します。

(env36)>dist\myproject.exe runserver
Watching for file changes with StatReloader
Performing system checks...

Traceback (most recent call last):
...
  File "myproject\urls.py", line 21, in <module>
  File "lib\site-packages\django\urls\conf.py", line 34, in include
  File "importlib\__init__.py", line 126, in import_module
  File "<frozen importlib._bootstrap>", line 994, in _gcd_import
  File "<frozen importlib._bootstrap>", line 971, in _find_and_load
  File "<frozen importlib._bootstrap>", line 953, in _find_and_load_unlocked
ModuleNotFoundError: No module named 'myapp.urls'
[2244] Failed to execute script manage

 
 
ここまで対応すると

(env36)>pyinstaller myproject.spec --onefile

73962 INFO: Appending archive to EXE path\to\django_pyinstaller_sample\dist\myproject.exe
73993 INFO: Building EXE from EXE-00.toc completed successfully.

と、exe化が成功します。

 
なお、この時の myprojct.spechiddenimports は以下の通りです。

hiddenimports=[
    'myapp.apps',
    'myapp.urls',
],

 
続いて、runserverして localhost:8000 にアクセスすると、Hello worldが表示されます。

(env36)>dist\myproject.exe runserver

  f:id:thinkAmi:20190421175503j:plain:w300

 

静的ファイル・Model・TemplateViewを使うDjangoアプリのexe化

次に、本格的なDjangoアプリで使われる

  • 静的ファイル
  • Model
  • TemplateView

を使うDjangoアプリをexe化してみます。

 
まずはModelを作ります。

myapp/models.py

from django.db import models


class Fruit(models.Model):
    name = models.CharField('名前', max_length=50)

    def __str__(self):
        return self.name

 
また、Modelの初期データを投入するfixtureを追加します。

myapp/fixtures/initial_data.json

[
  {
    "model": "myapp.Fruit",
    "pk": 1,
    "fields": {
      "name": "りんご"
    }
  },
  {
    "model": "myapp.Fruit",
    "pk": 2,
    "fields": {
      "name": "みかん"
    }
  }
]

 
ViewであるTemplateViewには、exe化した時の環境を確認するため、

  • Pythonのバージョン
  • settings.BASE_DIR

を表示してみます。

myapp/views.py

from django.views.generic import TemplateView
from django.conf import settings
import platform
from myapp.models import Fruit

class FruitTemplateView(TemplateView):
    template_name = 'myapp/fruit.html'
    extra_context = {
        'base_dir': settings.BASE_DIR,
        'python_version': platform.python_version(),
        'fruits': Fruit.objects.all(),
    }

 
templates/myapp/fruit.html

{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Index</title>
    <link rel="stylesheet" type="text/css" href="{% static 'css/myapp.css' %}">
</head>
<body>
<p class="version">Python version: {{ python_version }}</p>
<p>BASE_DIR: {{ base_dir }}</p>

<div>
    Fruit Model:
    <ul>
        {% for fruit in fruits %}
            <li>{{ fruit }}</li>
        {% endfor %}
    </ul>
</div>

<button id="show">say</button>

<script src="{% static "js/myapp.js" %}"></script>
</body>
</html>

 

versionを赤字で表示します。

static_files/css/myapp.css

.version {
    color: red;
}

 
ボタンをクリックしたときにalertを出すJavaScriptを作成します。

static_files/js/myapp.js

const btn = document.getElementById('show');

btn.addEventListener('click', () => {
    alert('hello');
});

 
プロジェクトのURLディスパッチャに、静的ファイル分を追加します。

myproject/urls.py

from django.contrib import admin
from django.urls import path, include
from django.contrib.staticfiles.urls import staticfiles_urlpatterns  # 追加

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('myapp.urls')),
]

urlpatterns += staticfiles_urlpatterns()  # 追加

 
アプリのURLディスパッチャには、今回作成したViewを追加します。

myapp.urls.py

urlpatterns = [
    path('', views.hello, name='hello'),
    path('fruit', views.FruitTemplateView.as_view(), name='fruit'),  # 追加
]

 
settins.pyには、静的ファイルの設定を追加します。
Pyinstaller compiles but 404 error on Django Static javascript files - Stack Overflow

myproject/settings.py

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'DIRS': [os.path.join(BASE_DIR, 'templates')],  # 変更
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [

# 追加
STATICFILES_DIRS = (
    os.path.join(BASE_DIR, 'static_files'),
)

 
あとはマイグレーション & fixtureによる初期データ投入を行います。

# マイグレーションファイルを作成する
(env36)>python myproject\manage.py makemigrations

# マイグレーション
(env36)>python myproject\manage.py migrate

# データ投入
(env36)>python myproject\manage.py loaddata initial_data
Installed 2 object(s) from 1 fixture(s)

 
ここまででDjangoアプリが作成できました。

続いてexe化の設定ファイルを修正します。

datas に、テンプレートと静的ファイルのパスをタプルで指定します。

myproject.spec

datas=[
    # (<myproject.specのディレクトリから見た各パス>, <exe化後に、Djangoアプリのrootから見た各パス>)
    ('myproject/templates', 'templates'),  # テンプレート
    ('myproject/static_files', 'static_files'),  # 静的ファイル
],

 
設定も終わったため、exe化と runserverします。

# exe化
(env36)>pyinstaller myproject.spec --onefile

# runserver
(env36)>dist\myproject.exe runserver

 
localhost:8000/fruit にアクセスすると

  • Pythonのバージョン
  • settings.BASE_DIR
  • Modelの内容

が表示されました。また、JavaScriptも動作しています。

f:id:thinkAmi:20190421190400p:plain

なお、 settings.BASE_DIR は一時ディレクトリのため、起動ごとに変更されることに注意します。

 

一時ディレクトリ外のSQLiteを使用するDjangoアプリをexe化

ここまでの方法では、SQLiteが一時ディレクトリにあるため、runserverするたびに初期化されてしまいます。

そのため、 %USERPROFILE% 直下にSQLiteを移動して使うよう変更します。
%USERPROFILE% env variable for python - Stack Overflow

myproject/settings.py

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(os.environ['USERPROFILE'], 'db.sqlite3'),
    }
}

 
また、fixtureを適用できるよう、exe化の設定ファイルを修正します。

myproject.spec

datas=[
    ('myproject/templates', 'templates'),
    ('myproject/static_files', 'static_files'),
    ('myproject/myapp/fixtures', 'myapp/fixtures'),  # 追加
],

 
他に、現在のSQLiteとは別のものを使っていることを確認するため、fixtureに追加します。

myproject/myapp/fixtures/initial_data.json

{
  "model": "myapp.Fruit",
  "pk": 3,
  "fields": {
    "name": "ぶどう"
  }
}

 
準備ができたので、exe化とlodadataとrunserverをして結果を確認します。

# exe化
(env36)>pyinstaller myproject.spec --onefile

# loaddata
(env36)>dist\myproject.exe loaddata initial_data
Installed 3 object(s) from 1 fixture(s)

# runserver
(env36)>dist\myproject.exe runserver

 
意図した通りに表示されました。

f:id:thinkAmi:20190421190340p:plain

 
また、 %USERPROFILE%\db.sqlite3 も作成されています。

>dir %USERPROFILE%\db*
...
2019/04/21  15:18           135,168 db.sqlite3

 
以上で、Djangoアプリをexe化できました。

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/Django_pyinstaller-sample