目次


Pyramid、SQLDB、Bluemix を使用してチャット・アプリを作成する

SQLAlchemy を使用して Bluemix 内でデータベース・サービスを統合する

Comments

Python の基礎知識があり、一から Web アプリを開発して IBM Bluemix にデプロイしたいと考えているとしたら、このチュートリアルが適しています。Pyramid フレームワークとそのセキュリティー・モデルを使用して開発する方法、Socket.IO 抽象化を使用してリアルタイムで通信する方法、そして SQLAlchemy による統合方法を学ぶことができます。SQLAlchemy は、Bluemix 環境でデータベース・サービスを簡単に統合する方法を提供します。

このチュートリアルでは、機能を 1 つずつ体系的に追加して、ユーザーがリアルタイムでメッセージをやりとりできるチャット・アプリ「Chatter」を作成します。メッセージはデータベースに格納されます。ユーザーがログインすると、アプリでやりとりした最新のメッセージが 10 件表示されます。このアプリでのログインの目的はユーザーを識別することだけなのでパスワードを使用しませんが、この手法は、典型的な認証と承認の要件に対しても適用することができます。

アプリを作成するために必要となるもの

アプリを実行するコードを入手する

ステップ 1. Pyramid と CF クライアントをインストールする

Ubuntu/Debian インストール済み環境での手順は以下のとおりです。

  1. Debian 用 CF クライアントの安定版インストーラーをダウンロードします。
  2. CF クライアントをインストールするために、root としてコマンド dpkg -i cf-cli_*.deb を実行します。
  3. Python パッケージ・マネージャー、開発環境、仮想環境をインストールするために、root としてコマンド apt-get install python-pip python-dev python-virtualenv を実行します。
  4. 個人用の仮想環境をセットアップするために、一般ユーザーとしてコマンド virtualenv $HOME/venv を実行します。
  5. コマンド . $HOME/venv/bin/activate を実行して、仮想環境をアクティブにします。

    source コマンドでこの activate ファイルを取り込むことにより、仮想環境から Python と pip が使用されるようになります。

  6. コマンド pip install pyramid を実行して、Pyramid をインストールします。

Windows インストール済み環境での手順は以下のとおりです。

  1. Windows 用 CF クライアントの安定版インストーラーをダウンロードします。
  2. ダウンロード・ファイルを解凍し、cf_installer.exe ファイルを実行します。
  3. プラットフォームに応じた Python MSI インストーラーをダウンロードします。
  4. ダウンロードしたファイルをインストールする際に、最初のページで「Install just for me (自分専用にインストールする)」を選択し、「Customize Python (Python のカスタマイズ)」ページで「Add python.exe to Path (python.exe をパスに追加する)」を選択します。
  5. MS Visual C++ Compiler for Python 2.7 をダウンロードしてインストールします。
  6. MS-DOS プロンプトを開き、コマンド pip install pyramid を実行して Pyramid フレームワークをインストールします。

ステップ 2. Chatter の最初のドラフトを作成する

Pyramid には、さまざまなテンプレート (scaffold) が用意されています。このチュートリアルの例では、以下のように SQLAlchemy scaffold を使用して Pyramid プロジェクトを作成します。

pcreate -s alchemy Chatter
cd Chatter

上記コマンドでは、ディレクトリーを作成し、カレント・ディレクトリーをそのディレクトリーに変更しましたが、その理由は、このチュートリアルでは、このディレクトリーを基準とした相対パスを使用することを前提としているためです。

使用するファイルは以下のとおりです。

ファイル説明
development.ini開発モードでアプリを実行するための構成ファイル
production.ini本番モードでアプリを実行するための構成ファイル
setup.pyアプリを実行可能な状態にセットアップして、配布用にパッケージ化するためのスクリプト・ファイル
chatter/__init__.pyアプリを構成するコードが含まれるファイル
chatter/models.pyアプリのモデル (データベースへのアクセスを扱うクラス) が含まれるファイル
chatter/views.pyアプリのロジックを制御する view 呼び出し可能メソッドが含まれるファイル
chatter/static ディレクトリーアプリで使用する静的リソースが含まれるディレクトリー
chatter/templates ディレクトリーvアプリで使用するページ・テンプレートが含まれるディレクトリー

このプロジェクトは自身へのパッケージ参照を使用します。参照が正しく機能するように、以下のコマンドを実行してプロジェクトを開発モードでインストールします。開発モードでインストールすると、setup.py ファイルの必須リストに記述された追加の依存関係もすべてインストールされます。

python setup.py develop

続いて以下のコマンドを実行して、アプリを起動します。

pserve production.ini

このコマンドは、アプリを現在実行しているプロセスと、アプリにアクセスするための URL をレポートした後、<Ctrl>-C キーの組み合わせで停止されるまで待機します。

production.ini に含まれるデフォルトのサーバー構成では、ポート 6543 でリッスンすることになっているので (ファイル内の port パラメーターを参照)、ブラウザーからは http://localhost:6543 でアプリにアクセスします。

現段階でブラウザーに表示されるのは、意匠を凝らしたページではなく、データベース内のテーブルが初期化されていないというメッセージです。このアプリの場合、別のステップでデータベース・テーブルを作成するのではなく、アプリの起動時にデータベース・テーブルを作成することをお勧めします。それには chatter/__init__.py ファイルで、行 Base.metadata.bind = engine を以下のように変更します。

    Base.metadata.create_all(bind = engine)

これにより、Base クラスを継承するすべてのテーブルが作成されますが、該当するテーブルがすでに存在する場合、そのテーブルは作成されません。production.ini ファイルにはデフォルトで、データベースに対して sqlalchemy.url = sqlite:///%(here)s/Chatter.sqlite が指定されます。これは、すべてのデータが Chatter.sqlite ファイルに保管されることを意味します。モデルに変更を加えた場合は、このファイルを削除してからアプリを起動するだけで変更が反映されます。

データベース内のテーブルを作成するための別個のステップはないので、以下のコマンドを実行して、chatter/scripts ディレクトリーを削除します。
rm -rf chatter/scripts (Unix の場合)
rmdir /s/q chatter\scripts (Windows の場合)

このチュートリアルで更新するファイルは静的ファイルなので、キャッシングを無効にしましょう (更新作業が完了した時点で再び有効にすることができます)。それには chatter/__init__.py 内にある config.add_static_view() の呼び出しで指定されているパラメーター cache_max_age=3600cache_max_age=0 に変更します。

アプリを起動してブラウザーでアクセスすると、Pyramid SQLAlchemy scaffold のウェルカム・ページが表示されるはずです。

Message クラスをモデルに追加する

このアプリのメッセージは、データベースに格納されます。使用するデータ構造が宣言される場所は、chatter/models.py ファイルです。このファイルにはすでにクラスの例が含まれていますが、この例を変更して、目的に叶った有効なクラスにする必要があります。class MyModel(Base): の行からファイルの終わりまでのすべての行を削除して、代わりに以下の行を追加します。

import datetime

from sqlalchemy import (
    Sequence,
    DateTime,
    )

class Message(Base):
    __tablename__ = 'messages'
    id = Column(Integer, Sequence('msid', optional=True), primary_key=True)
    text = Column(Text, nullable=False)
    created = Column(DateTime, default=datetime.datetime.utcnow)

    def __init__(self, text):
        self.text = text

上記のコードが宣言するクラスは、(__tablename__ 属性で指定された) 'messages' テーブルにアクセスします。このテーブルには、id (主キー、Integer 型)、text (Text 型)、created (DateTime 型) という 3 つの列があります。宣言されたクラスは、Base クラス (chatter/model/meta.py ファイルで宣言) を継承し、このクラスを SQLAlchemy フレームワークに組み込むとともに、Base インスタンスが保持するメタデータに追加します。また、Sequence() を呼び出すことにより、id 列がオートインクリメントされた値となるようにしています。

created 属性は、インスタンスが作成される時刻をデフォルト値として使用します。ここでは、タイム・ゾーンに依存しない値で時刻を保管するために、datetime.datetime.utcnow を使用しています。これは、関数を参照しているのであって、関数を呼び出しているのではないことにも注意してください。datetime.datetime.utcnow() に変更して関数呼び出しに変えたとすると、デフォルト値は各 Message インスタンスが作成される時刻ではなく、Message クラスが初期化された時刻を宣言したものになります。

MyModel を削除したので、Chatter.sqlite も削除してください。

メッセージの view 呼び出し可能オブジェクトを追加する

Pyramid の View callable (呼び出し可能オブジェクト) は、成果物のリクエストを処理してレスポンスに変換します。chatter/views.py ファイルの内容を以下のように変更してください。

from pyramid.httpexceptions import HTTPFound
from pyramid.view import view_config
import transaction

from .models import (
    DBSession,
    Message,
    )

@view_config(route_name='home', renderer='templates/messages.pt')
def index(request):
    messages = DBSession.query(Message).order_by(Message.created)[-10:]
    return {'messages': messages}

@view_config(route_name='send')
def send(request):
    text = request.params.get('message')
    with transaction.manager:
        message = Message(text)
        DBSession.add(message)
    return HTTPFound(location=request.route_url('home'))

index 呼び出し可能オブジェクトは、経路 'home' に関連付けられています。デフォルトで、この経路はアプリの / URL に関連付けられます。このオブジェクトは、chatter/model.py ファイルで宣言された Message クラスを使用して、データベースから最新のメッセージを最大 10 件取得し、これらのメッセージをレンダリング・テンプレート templates/messages.pt の 'message' パラメーターとして返します

send 呼び出し可能オブジェクトは、経路 'send' (次のステップで追加します) に関連付けられています。このオブジェクトは、templates/messages.pt ファイルにおけるフォームからの送信リクエストを処理します。その方法は単に、DBSession クラスと Message クラスを使用して新しい行を挿入し、リクエストを再び経路 'home' にリダイレクトするというものです。POST タイプの送信の後にリダイレクトを行うのは、ユーザーがページをリロードした場合にフォームが再び送信されないようにするための有効なプラクティスです。私は route_url を使用してリダイレクト先を見つけることで、このコードが URL の経路である 'home' のマッピング先に依存しないようにしました。

経路 'send' のマッピングを chatter/__init__.py ファイルに追加してください (行 config.scan() の前に追加します)。

    config.add_route('send', '/send')

messages.pt テンプレートを追加する

使用しないファイルが残らないように、作業を進める前に、以下のコマンドを使用してすべてのテンプレートと静的ファイルを削除します。
rm -f chatter/templates/* chatter/static/* (Unix の場合)
del /q chatter\templates chatter\static (Windows の場合)

デフォルトでテンプレートの Chameleon レンダリング・エンジンが用意されているので、これを使用します。chatter/templates/messages.pt ファイルを作成して (.pt 拡張子は、このファイルが Chameleon エンジンによって処理されることを意味します)、以下の内容のコンテンツを含めます。

<html>
  <head>
    <title>Messages</title>
    <link rel="stylesheet" type="text/css" href="/static/messages.css"/>
  </head>
  <body>
    <div id="chatlog">
      <ul>
        <li tal:repeat="m messages" tal:content="m.text" />
      </ul>
    </div>
    <hr/>
    <form action="${request.route_url('send')}" method="post">
      <input id="send" name="send" type="submit" value="Send" />
      <input id="message" name="message" type="text" />
    </form>
  </body>
</html>

このテンプレートは、view 呼び出し可能オブジェクトから渡されたすべてのメッセージのリストが含まれるページと、メッセージを経路 'send' に送信するためのフォームを作成します。tal:repeat="m messages" という属性は、Chameleon エンジンが解釈し、配列 'messages' に含まれる項目ごとに、'm' 変数を使用して <li> 要素を作成します。その要素 (<li> と </li>の間) に "m.text" の値を挿入するようにテンプレート・エンジンに指示するのが、tal:content="m.text" 属性です。

順序なしリストとして項目が並べられたテキスト表示にするのではなく、メッセージがやりとりされているようなテキスト表示にするために、スタイルシート chatter/static/messages.css を作成して、以下の内容のコードを含めます。

#chatlog > ul {
  padding: 0 0;
  list-style-type: none;
}

以上がアプリの最初のイテレーションです。コマンド pserve production.ini でアプリを起動して、http://localhost:6543 でアプリにアクセスしてください。「Send (送信)」ボタンをクリックすると、データベースから最新のメッセージが 10 件表示されます。このページに複数のユーザーがアクセスすると、ユーザーには互いのメッセージが表示されます。

ここまでの時点でのコードを確認するには、Git リポジトリーのタグ step2-first-draft を使用してください。

ステップ 3. SocketIO を使用して Chatter をさらに動的にする

複数のユーザーがメッセージを送信するとしたら、現時点でのアプリはあまり有用ではありません。なぜなら、ユーザーは自分のメッセージを送信したときでないと、他のユーザーのメッセージを見られないためです。そこで、サーバーに接続しているすべてのユーザーに、すぐにメッセージが表示されるようにしましょう。そのために追加する必要があるのは、(当初は NodeJS 用に開発された) SocketIO 抽象化のサポートです。

スターター・スクリプトを作成する

まず、アプリの起動方法を調整します。pserve コマンドではサーバーを選択できないため、独自のスターター・スクリプトを用意する必要があります。カレント・ディレクトリーに、serve.py ファイルを作成して以下の行を含めます。

import os
from paste.deploy import loadapp
from socketio.server import serve
from gevent import monkey; monkey.patch_all()

if __name__ == '__main__':

    app = loadapp('config:production.ini', relative_to=os.getcwd())
    try:
        serve(app, host='0.0.0.0')
    except KeyboardInterrupt:
        pass

socketio が含まれるパッケージをインストールする必要もあります。setup.py ファイル内の requires[] リストに行 gevent-socketio を追加してから、python setup.py develop を再び実行します。

このスクリプトは、デフォルトの WSGIServer ではなく SocketIOServer 実装を使用してリクエストをリッスンします。この段階でスクリプトをテストするために、python serve.py を実行して、アプリの動作が変わっていないことを確認してください。

SocketIO のサーバー・サイドを更新する

受信されるリクエストを send 呼び出し可能オブジェクトが処理する方法を変更するには、chatter/views.py 内の send メソッドの宣言 (行 @view_config(route_name='send')) からファイルの終わりまでを以下のコードで置き換えます。

from socketio import socketio_manage
from socketio.mixins import BroadcastMixin
from socketio.namespace import BaseNamespace
from pyramid.response import Response

@view_config(route_name='send')
def send(request):
    socketio_manage(request.environ, {'/chat': ChatNamespace}, request)
    return Response('')

class ChatNamespace(BroadcastMixin, BaseNamespace):
    def on_chat(self, text):
        with transaction.manager:
            message = Message(text)
            DBSession.add(message)
            self.broadcast_event('chat', text)

これで、send 経路に対するリクエストが SocketIO の特殊な方法で処理されて、/chat 名前空間でのリクエストの処理が ChatNamespace クラスに委任されるようになりました。リクエストの処理が委任された ChatNamespace クラスは、chat イベントを処理する方法として、メッセージをデータベースに追加し、サーバーに接続されているすべてのクライアントにメッセージ・テキストをブロードキャストします。

SocketIO リクエストは、/socket.io/ で始まる特殊なパスを介して送信されるため、chatter/__init__.py 内の send 経路に対するマッピングも調整する必要があります。行 config.add_route('send', '/send') を以下のように変更してください。

    config.add_route('send', '/socket.io/*remaining')

SocketIO のクライアント・サイドを更新する

以上の変更により、サーバー・サイドでの SocketIO 通信には対処できました。クライアントについては、このテンプレートを調整し、SocketIO 交換を処理する JavaScript を追加します。

chatter/templates/messages.pl 内のフォーム宣言 (<form> 要素と </form> 要素の間) を以下のように変更します。

    <form id="chatform">
      <input id="send" name="send" type="submit" value="Send" />
      <input id="message" name="message" type="text" />
    </form>
    <script src="//cdn.jsdelivr.net/jquery/2.1.1/jquery.min.js"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/socket.io/0.9.16/socket.io.min.js"></script>
    <script src="/static/chat.js" type="text/javascript"></script>

上記の変更では、送信フォームに id を追加して、JavaScript コードがこれを見つけられるようにしました。また、一連の JavaScript ファイルも追加しています。そのなかの jquery.min.js ファイルは、ページ上の要素の検出を簡素化するためのファイルです。

注: socket.io.js のバージョン 0.9.16 を使用してください。このバージョンは、gevent-socketio 実装でサポートされている (gevent-socketio 実装に対応している) からです。socket.io.js のバージョン 1.0 には、SocketIO の以前のバージョンには非対応の変更が導入されています。

アプリの SocketIO を処理するのは chat.js スクリプトなので、chatter/static/ 内に chat.js スクリプト・ファイルを作成して、以下のコードを含めます。

$(document).ready(function() {
  var socket = io.connect('/chat')

  $(window).bind("beforeunload", function() {
    socket.disconnect();
  });

  var chatlog = $("#chatlog>ul")

  var addMessage = function(data) {
    chatlog.append($('<li>').append(data));
  }

  socket.on("chat", function(e) {
    addMessage(e);
    chatlog.scrollTop(chatlog.height());
  });

  $("#chatform").submit(function(e) {
    // don't allow the form to submit
    e.preventDefault();

    // send out the "chat" event with the textbox as the only argument
    socket.emit("chat", $("#message").val());
    $("#message").val("");
  });
});

このスクリプトの処理内容は以下のとおりです。

  • '/chat' 名前空間 (view 呼び出し可能オブジェクト内で ChatNamespace がマッピングされている名前空間) に接続します。
  • chat イベント (on_chat メソッドで ChatNameserver によってブロードキャストされるイベント) をリッスンし、受信したデータを格納した '<li>' 要素を chatlog に追加します。
  • chatform が送信されると、chat イベント (ChatNameserveron_chat メソッドで処理するイベント) を送信します。

python serve.py でアプリを起動すると、今回は、アプリに接続されたすべてのユーザーからのメッセージがすぐに表示されます。ただし、すべての新規メッセージで chatform が表示されてしまいます。これは、一般的なチャット・アプリの動作ではありません。そこで、必要なスタイル・プロパティーを chatter/static/messages.css に追加して、新しいファイル・コンテンツにします。

#chatlog > ul {
  padding: 0 0;
  list-style-type: none;
  height: 200pt;
  overflow-y: scroll;
}
#message {
  width: 80%;
}

これで、チャット・アプリらしくなるはずです。

ここまでの時点でのコードを確認するには、Git リポジトリーのタグ step3-add-socketio を使用してください。

ステップ 4: ユーザーを Chatter に追加する

ユーザーにログインするよう要求する

以下のようにして、ログインしてからでないとユーザーはチャットに参加できないようにします。

  1. アプリの認証および承認ポリシーを有効にします。
  2. 権限がなければ、view 呼び出し可能オブジェクトにアクセスできないようにします。
  3. 権限が設定されたプリンシパルにユーザーをマッピングします。
  4. ユーザーが自身を認証するためのログイン・ビューを追加します。

chatter/__init__.py で、main 関数の宣言の前に以下の行を追加します。

from pyramid.security import Allow, Everyone
from pyramid.authentication import AuthTktAuthenticationPolicy
from pyramid.authorization import ACLAuthorizationPolicy

class Root:
    __acl__ = [ (Allow, 'group:view', 'view') ]
    def __init__(self, request):
        pass

def groupfinder(userid, request):
    return ['group:view']

このコードが追加する Root クラスは、アプリに対するすべてのリクエストのコンテキストとして使用されます。このクラスは、'group:view' のメンバーに 'view' 権限を付与し、他のすべてのメンバーがアクセスするのを禁止します。userid が属するグループのリストを取得するために、groupfinder 関数が追加されています。アプリのすべてのユーザーは、'group:view' に属します。

これらのオブジェクトをアプリで使用し始めるには、同じファイル (chatter/__init__.py) の main 関数で、行 config = Configurator(settings=settings) を以下のように変更します。

    config = Configurator(root_factory=Root, settings=settings)
    config.set_authentication_policy(AuthTktAuthenticationPolicy(
        'chattersecret', groupfinder, hashalg='sha512'))
    config.set_authorization_policy(ACLAuthorizationPolicy())
    config.set_default_permission('view')
    config.add_route('logout', '/logout')

これで、Root クラスがアプリのルート・コンテキストとして設定されました。このコードはまた、(AuthTktAuthenticationPolicy インスタンスによって) 認証情報に auth_tkt cookie を使用するポリシーを設定します。このポリシーでは、認証情報の暗号化にキーワード 'chattersecret' を使用し (各アプリに固有になります)、ハッシュ化には 'sha512' アルゴリズムを使用します。さら、認証情報を使用してユーザーが属するグループを検出するために、groupfinder メソッドをコールバックとして使用します。このコードは承認ポリシーとして ACL を使用するため、コンテキスト・オブジェクトへのアクセスは '__acl__' クラス属性として指定されます。

この時点でアプリを実行しようとすると、403 Forbidden エラーを受け取ります。これは、ユーザーが自身を認証する方法がまだ用意されていないからです。このエラーを解決するには、chatter/views.py ファイルに forbidden_view を追加する必要があります。ファイルの終わりに、以下の行を追加してください。

from pyramid.view import forbidden_view_config
from pyramid.security import remember, forget

@forbidden_view_config(renderer='templates/login.pt')
def login(request):
    error = ''
    if 'login' in request.params:
        username = request.params['username']
        if not username:
            error = "user name can not be empty"
        else:
            headers = remember(request, username)
            return HTTPFound(location=request.route_url('home'),
                headers=headers)
    return {'error': error}

@view_config(route_name="logout")
def logout(request):
    headers = forget(request)
    return HTTPFound(location=request.route_url('home'), headers=headers)

forbidden_view_config デコレーターにより、アプリはデフォルトの禁止エラー・ページを返すのではなく、この view 呼び出し可能オブジェクトを呼び出すようになります。ログイン・ページの送信時にも、この同じ呼び出し可能オブジェクトが使用されますが、この 2 つのイベントは、'login' (login.pt での送信ボタンの名前) の有無によって区別されます。ここではとりあえず、提供されたユーザー名が空のストリングでないことだけを確認します。この条件が満たされると、ユーザー情報が保管され、コールバックが 'home' にリダイレクトされます。条件が満たされない場合、エラー・メッセージを表示するログイン・ページに戻ります。

logout 呼び出し可能オブジェクトにより、ユーザーは「忘れてくれるように」とのリクエストを送信することができます。

ログイン・ページのテンプレートを chatter/templates/login.pt ファイルに追加します。

<html>
  <head>
    <title>Login</title>
    <link rel="stylesheet" type="text/css" href="/static/login.css" />
  </head>
  <body>
    <span id="error" tal:condition="error" tal:content="error" />
    <form method="post">
Username: <input id="username" name="username" type="text" />
      <input id="login" name="login" value="Login" type="submit" />
    </form>
  </body>
</html>

tal:condition="error" tal:content="error" は、error の値が空でない場合にのみ、この span 要素が追加されることを意味します。この要素の中身は error の値です。その他すべての側面においては、このテンプレートは単純なログイン・フォームになっています。

エラー・メッセージを目立たせるために、chatter/static/login.css ファイルを作成して以下のコードを追加します。

#error {
color: red;
}

ユーザーがログアウトできる必要があるので、chatter/templates/messages.pl で、chatform の後に以下のコードを追加します。

    <form action="${request.route_url('logout')}" method="post">
 ${request.authenticated_userid}: <input id="logout" type="submit" value="Logout" />
    </form>

これで、名前を指定するだけでアプリにログインできるようになったはずです。

ここまでの時点でのコードを確認するには、Git リポジトリーのタグ step4a-add-login を使用してください。

ユーザー・クラスをモデルに追加する

これまでに行った変更では、まだメッセージを送信者に関連付けていないので、ユーザーをモデルに追加して、メッセージをユーザーに関連付ける必要があります。そのために chatter/models.py に追加する User クラスと、更新する Message クラスを以下に示します (class Message(Base) で始まる行からファイルの終わりまでを置き換えます)。

from sqlalchemy import (
    Unicode,
    ForeignKey,
    )
from sqlalchemy.orm import relation

class Message(Base):
    __tablename__ = 'messages'
    id = Column(Integer, Sequence('msid', optional=True), primary_key=True)
    text = Column(Text, nullable=False)
    created = Column(DateTime, default=datetime.datetime.utcnow)
    userid = Column(Integer, ForeignKey('users.id'), nullable=False)

    def __init__(self, user, text):
        self.user = user
        self.text = text

class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, Sequence('usid', optional=True), primary_key=True)
    name = Column(Unicode(255))
    messages = relation(Message, backref="user")

    def __init__(self, name):
        self.name = name

'users.id' 列の ForeignKey として、User クラスで使用するテーブル名で索引が付けられた userid 属性が追加されていることに注目してください。これと同じ関係が、User クラスの messages 属性の宣言でも使用されます。relation(Message) の呼び出しによって作成されるクエリー属性が、User の主キーと一致する外部キーを持つ Message インスタンスの配列を返します。backref="user" オプションは、Message クラスに User 属性を追加して、外部キーの参照先となっている user インスタンスを返します。

データベース・モデルが変更されたので、データベースを再作成します。それには、単に古いデータベース (Chatter.sqlite ファイル) を削除します。次にアプリを実行するときに、適切なモデルを使用してデータベースが再作成されます。

ユーザー情報を考慮してユーザーの重複を回避するように login 呼び出し可能オブジェクトおよび messages 呼び出し可能オブジェクトを調整するには、リクエストに username ではなく userid を格納します。chatter/views.py 内の login メソッドの宣言で、行 headers = remember(request, username) を以下のように変更します。

            with transaction.manager:
                users = DBSession.query(User).filter_by(name=username).all()
                if users:
                    user = users[0]
                else:
                    user = User(name=username)
                    DBSession.add(user)
                    DBSession.flush()
                headers = remember(request, user.id)

remember の呼び出しは、with transaction.manager のスコープ内になければ、User インスタンス属性にアクセスすることができません。上記のコードには、DBSession.flush() の呼び出しも追加されています。これは、ユーザーの追加リクエストをデータベースに渡して新しい userid を作成するためです。こうすることにより、この属性を user インスタンスから取得できるようにします。

次の変更は、chat イベントを処理するコードで行う必要があります。それは、Message インスタンスに User を指定しなければならなくなったためです。ChatNamespace クラスの更新後の on_chat メソッドは以下のようになります。

    def on_chat(self, text):
        with transaction.manager:
            user = DBSession.query(User).get(self.request.authenticated_userid)
            if user:
                message = Message(user, text)
                DBSession.add(message)
                self.broadcast_event('chat', text)

Logout ボタンの前で使用する名前を修正します。それには、ユーザーの名前を別のエントリーとしてテンプレートに送信する必要があるので、index メソッドの宣言を以下のように変更します。

from .models import User

@view_config(route_name='home', renderer='templates/messages.pt')
def index(request):
    user = DBSession.query(User).get(request.authenticated_userid)
    if user:
        messages = DBSession.query(Message).order_by(Message.created)[-10:]
        return {'messages': messages, 'username': user.name}
    else:
        return logout(request)

chatter/templates/messages.pt も更新する必要があります。logout 送信ボタンの行を以下のように更新してください。

${username}: <input id="logout" type="submit" value="Logout" />

これで、アプリはこれまでと同じように機能しながらも、ユーザーとメッセージの関連付けを保管するようになりました。

ここまでの時点でのコードを確認するには、Git リポジトリーのタグ step4b-user-info を使用してください。

ユーザー情報と時刻情報を chat イベントに追加する

ユーザーとメッセージを関連付けることの本質は、この情報を出力で使用できるようにすることにあります。この情報をブラウザーで使用できるように、SocketIO リクエストを調整しましょう。

クライアントに渡される単純なストリングよりも複雑な構造になるので、クライアントに最初に表示される過去のメッセージもフォーマット設定することにします。フォーマット設定に対処するとともにコードの重複を回避するには、chat リクエストを変更して、1 つのオブジェクトだけではなく、常にメッセージ・オブジェクトの配列を送信するようにします。それには、chatter/views.py で ChatNamespace クラスの宣言を以下のように変更します。

def mToD(message):
    return {'who': message.user.name,
            'when': message.created.isoformat(),
            'what': message.text}

class ChatNamespace(BroadcastMixin, BaseNamespace):
    def recv_disconnect(self):
        self.disconnect(silent=True)

    def on_history(self):
        self.emit('chat', [mToD(m) for m in DBSession.query(Message).order_by(
                  Message.created)[-10:]])

    def on_chat(self, text):
        with transaction.manager:
            user = DBSession.query(User).get(self.request.authenticated_userid)
            if user:
                message = Message(user, text)
                DBSession.add(message)
                DBSession.flush()
                self.broadcast_event('chat', [mToD(message)])

index メソッドでのメッセージの追加も削除する必要があります。このメソッドを以下のように変更してください。

def index(request):
    user = DBSession.query(User).get(request.authenticated_userid)
    if user:
        return {'username': user.name}
    else:
        return logout(request)

chatter/templates/messages.pt でも、メッセージの追加を削除します。以下の行を削除してください。

        <li tal:repeat="m messages" tal:content="m.text" />

以下に、chatter/static/chat.js での対応する変更を示します (addMessage 関数宣言および chat イベントのハンドラーで変更された行だけを記載してあります)。

  var prevDateString = '';

  var addMessage = function(data) {
    currDate = new Date(data.when);
    currDateString = currDate.toDateString()
    if (currDateString !== prevDateString) {
      chatlog.append($('<li>').attr('class', 'date').text(currDateString));
      prevDateString = currDateString;
    }
    chatlog.append($('<li>').append(
      $('<span>').attr('class', 'when').text("["+currDate.toLocaleTimeString()+
                                             "]")).append(" ").append(
      $('<span>').attr('class', 'who').text(data.who + ":")).append(" ").append(
      $('<span>').attr('class', 'what').text(data.what)));
  }

  socket.on("chat", function(e) {
    for (i=0; i<e.length; i++) {
      addMessage(e[i]);
    }
    chatlog.scrollTop(chatlog.height());
  });

  socket.emit("history")

メッセージの各構成部分の違いを際立たせるために、以下の行を chatter/static/messages.css に追加します。

.date {
  text-align: center;
  font-size: 140%;
}
.who {
  font-weight: bold;
}
.when {
  font-style: italic;
  color: gray;
}

以上の変更作業が終わったら、python serve.py を実行してアプリを試してください。

ここまでの時点でのコードを確認するには、Git リポジトリーのタグ step4c-display-usertime を使用してください。

オンライン・ユーザー情報を追加する

ほとんどのチャット・アプリでは、ユーザーに会話の参加者がわかるようになっています。作成するアプリでこの機能を実現するには、新しいテーブル・クラス Connect を追加して、そこに接続情報を格納します。chatter/models.py ファイルに、以下のクラス宣言を追加します。

from sqlalchemy import Boolean

class Connect(Base):
    __tablename__ = 'connects'
    id = Column(Integer, Sequence('cnid', optional=True), primary_key=True)
    userid = Column(Integer, ForeignKey('users.id'), nullable=False)
    time = Column(DateTime, default=datetime.datetime.utcnow)
    ison = Column(Boolean)

    def __init__(self, user, ison):
        self.userid = user.id
        self.ison = ison

このテーブルには、ユーザーによる connect イベントおよび disconnect イベントごとのレコードを格納します。モデルの既存のテーブルの宣言には変更を加えていないため、Chatter.sqlite を削除する必要はありません。新しいテーブルは、次にアプリを実行した時点で既存のテーブルに追加されます。

ユーザーのステータス情報は、status イベントでクライアントに送信されます。このイベント・データには、ユーザーとその現在のステータス ('on' または 'off') からなる配列が含まれます。以下のように chatter/views.py 内で、recv_disconnect メソッドによる connect イベントの処理を追加し、recv_disconnect および on_history メソッドを更新します (on_chat メソッドは現状のままにします)。

from sqlalchemy import func
from .models import Connect

lastIdQuery = DBSession.query(func.max(Connect.id).label('max')).group_by(
              Connect.userid).subquery()
connQuery = DBSession.query(Connect).join(
            lastIdQuery, Connect.id == lastIdQuery.c.max).subquery()
connectedUsers = DBSession.query(User).join(connQuery).filter(connQuery.c.ison)

def disconn_all():
    with transaction.manager:
        for user in connectedUsers:
            DBSession.add(Connect(user, False))

def uToD(user, ison):
    return {'id': user.id,
            'name': user.name,
            'ison': ison}

class ChatNamespace(BroadcastMixin, BaseNamespace):
    def recv_connect(self):
        self._connect_event(True)

    def recv_disconnect(self):
        self._connect_event(False)
        self.disconnect(silent=True)

    def _connect_event(self, ison):
        with transaction.manager:
            user = DBSession.query(User).get(self.request.authenticated_userid)
            if user:
                DBSession.add(Connect(user, ison))
                self.broadcast_event('status', [uToD(user, ison)])

    def on_history(self):
        self.emit('chat', [mToD(m) for m in DBSession.query(Message).order_by(
                  Message.created)[-10:]])
        self.emit('status', [uToD(u, True) for u in connectedUsers])

上記のコードの内容は以下のとおりです。

  • connect イベントおよび disconnect イベントが発生すると、新しいレコードが Connect テーブルに追加され、すべてのユーザーに status イベントがブロードキャストされます。
  • クライアントから history リクエストがあった時点で、現在接続中のユーザーのリストが status イベントと chat イベントで送信されます。
  • lastIdQuery は、connectedUsers に関して Connect レコードから各ユーザーの最大の ID を返します。すると、connQuery がこれらの ID に対応するレコード (各ユーザーの最新レコード) を Connect テーブルから返します。connectedUsers は、Connect テーブルの最新レコードで ison 値が True に設定されたユーザーを返します。

同じユーザーが同時に複数回接続されることがないように、同じ chatter/views.py ファイルで、login メソッド内の user クエリーおよび検証を以下のように更新します (else 条件は現状のままにします)。

                users = DBSession.query(User, connQuery.c.ison).join(
                        connQuery).filter(User.name==username).all()
                if users:
                    user = users[0][0]
                    if users[0][1]:
                        return {'error': 'User "%s" is already connected' % user.name}
                else:

サーバーが再起動されると、クライアントが再び接続される前に、すべてのユーザーを切断状態としてマークします。そのためには、上記のコードで導入された disconn_all() メソッドをアプリの起動時に呼び出す必要があります。chatter/__init__.py で、すべてのテーブルを作成する create_all() 呼び出しの後に以下の行を追加します。

    from .views import disconn_all
    disconn_all()

クライアントでは、メッセージ・ログとユーザーのリストが並べて表示されます。したがって、chatter/templates/messsages.pt 内の <div id="chatlog"> を、以下のように表に変更します。

    <table>
      <tr>
        <td id="chatlog">
          <ul/>
        </td>
        <td id="users">
          <ul/>
        </td>
      </tr>
    </table>

このビューをそれなりの外観にするために、以下のエントリーを chatter/static/messages.css に追加します。

table {
  width: 100%;
}
#chatlog {
  width: 80%;
  vertical-align: top;
}
#users {
  vertical-align: top;
}
#users > ul {
  padding: 0 0;
  list-style-type: none;
}

ユーザーの ison ステータスに応じてユーザーの追加または削除を行う status イベントの処理を chatter/static/chat.js に追加します。

  var users = $("#users>ul");

  var updateStatus = function(data) {
    user = $("#user-" + data.id);
    if ((user.length === 0) === data.ison) {
      if (data.ison) {
        users.append($('<li>').text(data.name).attr('id', "user-" + data.id));
      } else {
        user.remove();
      }
    }
  }

  socket.on("status", function(e) {
    for (i=0; i<e.length; i++) {
      updateStatus(e[i]);
    }
  });

現時点で、アプリは現在接続中のユーザーを表示するようになっています。これで、静的ファイルの変更は完了したので、chatter/__init__.py で cache_max_age=0cache_max_age=3600 に戻して構いません。

ここまでの時点でのコードを確認するには、Git リポジトリーのタグ step4-add-users を使用してください。

ステップ 5. Chatter を Bluemix にデプロイする

アプリを Bluemix にデプロイするのは実に簡単です。アプリを Bluemix に繰り返しデプロイする作業を容易にするために、以下の内容を含めた manifest.yml ファイルを作成します。

---
applications:
- name: TestChatter
  memory: 128M
  buildpack: https://github.com/cloudfoundry/cf-buildpack-python.git
  command: python serve.py

上記のコードは、TestChatter という名前のアプリをプッシュする際に、128 M のメモリーを割り当て、Python ビルドパックを使用し、起動スクリプトとして serve.py を使用するよう、CF クライアントに指示します。

アプリと一緒に不要なファイルが Bluemix にプッシュされないように、以下の内容が含まれた .cfignore ファイルを作成します。

Chatter.sqlite
dist

VCAP_APP_PORT を処理する

アプリが Bluemix で機能するためには、正しいポートをリッスンしなければなりません。serve.py で、serve 呼び出しに port パラメーターを追加します。

        serve(app, host='0.0.0.0', port=os.getenv('VCAP_APP_PORT', 6543))

ここで Bluemix にログインしてアプリをプッシュし、アプリの URL を指定してアプリにアクセスしてください。

cf login -a https://api.ng.bluemix.net
cf push

VCAP_SERVICES を処理する

アプリが再起動すると、会話のログは消えてしまいます。ログを永続的に使用可能にするために、Bluemix のデータベース・サービスを利用します。chatter/__init__.py で、行 engine = engine_from_config(settings, 'sqlalchemy.') を以下のように変更し、データベース・エンジンの初期化方法を変更します。

    engine = None
    import os
    if os.environ.has_key("VCAP_SERVICES"):
        import json
        services = json.loads(os.environ["VCAP_SERVICES"])
        for service in reduce(lambda x,y: x+y, services.values(), []):
            if service['name'] == 'messages':
                from sqlalchemy import create_engine
                engine = create_engine(service['credentials']['uri'], pool_recycle=3600)
    if not engine:
        engine = engine_from_config(settings, 'sqlalchemy.')

上記のコードは、'messages' というサービスによって、使用するデータベースの URI を取得します。このサービスは、長時間維持されている接続を定期的にクローズします。この処理を適切に行うには、定期的に接続のリサイクル (接続をクローズしてオープンする処理) を行います、pool_recycle=3600 の設定により、オープンしている時間が 1 時間を超える接続に対して、接続のリサイクルが行われることになります。URI に加え、データベースのドライバーも追加する必要があります。これらのドライバーは、setup.py の requires[] リストに追加されます。行 psycopg2 を使用して PostgreSQL を追加すると、アプリを PostgreSQL データベースで使用できるようになります。

cf create-service postgresql 100 messages
cf bind-service TestChatter messages
cf push

これで、アプリが再起動されても、データが維持されるようになりました。

cf restart TestChatter

DB2 のサポートを追加する

DB2 にはオープンソースのクライアントがないため、必要なライブラリーを直接アプリに追加します。IBM Data Server Client Packages のフィックスパックをダウンロードするサイトから Linux x86 64 ビット用の IBM Data Server Driver Package をダウンロードして、アプリのルートに配置してください。Linux 以外のプラットフォームでアプリを開発しているとしても、アプリは Bluemix の Linux プラットフォーム上で実行されるため、Linux x86 64 ビット用のダウンロードを使用する必要があります。このドライバーの V10.5 Fix Pack 5 バージョンのファイルは、v10.5fp5_linuxx64_dsdriver.tar.gz です。

DB2 パッケージのインストールを setup.py に追加します。デフォルトの pip パッケージ・マネージャーはプリパッケージされたファイルを処理しないため、代わりに easy_install を使用します。setup.py ファイルの setup() 呼び出しの前に以下の行を追加してください。

if "-".join(os.uname()[0::4]) == "Linux-x86_64":
    from glob import glob
    from subprocess import call

    if not os.path.isdir('./dsdriver'):
        db2_drivers=glob("./v*_linuxx64_dsdriver.tar.gz")
        if db2_drivers:
            import tarfile
            print "Using %s" % db2_drivers[0]
            tar = tarfile.open(db2_drivers[0])
            tar.extractall()
            tar.close()
            call(["bash", "./dsdriver/installDSDriver"])

            for f in db2_drivers:
                os.remove(f)
            import shutil
            for d in os.listdir("./dsdriver"):
                if not d in ["lib", "python"]:
                    f = os.path.join("./dsdriver", d)
                    if os.path.isdir(f):
                        shutil.rmtree(f)
                    else:
                        os.remove(f)

    if os.path.isdir('./dsdriver/python/python64'):
        call(["easy_install", "-N", glob("./dsdriver/python/python64/ibm_db-*-linux-x86_64.egg")[0]])
        call(["easy_install", "-N", glob("./dsdriver/python/python64/ibm_db_sa-*.egg")[0]])

以下の点に注意してください。

  • インストールが実行されるのは、OS に対応するドライバーが実際にある場合のみです。また、ファイルの解凍が行われるのは、まだ解凍が行われていない場合のみです。
  • easy_install が、新しいパッケージの存在をネットワーク上で確認することがないように、-N オプションを指定します。
  • コードがどのバージョンのパッケージでも動作するように、glob を使用します。
  • ドライバーのインストールと Python パッケージのインストールを、2 つの別々の if 条件に分け、インストール済みのドライバーをアプリに組み込むことで、Linux にアクセスするユーザーがより短時間でアプリの初期起動を行えるようにします。
  • 最初の if ブロックの終わりでファイルを削除すると、クラウド上のアプリのディスク占有スペースが削減されます。

最後に、DB2 ネイティブ・ライブラリーをライブラリー・ロード・パスに追加します。ライブラリーのロードはアプリの環境内で行われるため、manifest.yml ファイルを以下の行で更新します。

  env:
   LD_LIBRARY_PATH: /app/dsdriver/lib:$LD_LIBRARY_PATH

これで、アプリが DB2 をサービスとして使用できるようになりました (最初に、以前のサービス・インスタンスを削除してください)。

cf unbind-service TestChatter messages
cf delete-service -f messages
cf create-service sqldb sqldb_free messages
cf bind-service TestChatter messages
cf push

ここまでの時点でのコードを確認するには、Git リポジトリーのタグ step5-bluemixify を使用してください。

まとめ

このチュートリアルでは、以下の方法を紹介しました。

  • Pyramid ベースのアプリを作成し、アプリがデータベースに依存しないように、そのデータベース・モデルを抽象化する方法。
  • SocketIO 抽象化を使用してページが動的に更新されるようにする方法。
  • ユーザーがアプリを使用する前に、ユーザー認証を適用する方法。
  • アプリを Bluemix 環境にデプロイする方法。
  • Bluemix に用意されたデータベース・サービスを利用する方法。

これで、Bluemix 環境で Pythonベースのアプリを作成する基礎が整いました。

現状のサンプル・アプリには、インターフェースからはアクセスできない有用な情報が含まれています。これは、皆さんがこのチュートリアルで身に付けたスキルを利用して、アプリを拡張するためのお膳立てだと思ってください。例えば、保管されているすべてのメッセージをスクロールできるようにするとか、メッセージの検索機能を追加する、あるいは 1 日の訪問者数でアプリの人気度を表示するといった拡張が可能です。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Cloud computing
ArticleID=1002330
ArticleTitle=Pyramid、SQLDB、Bluemix を使用してチャット・アプリを作成する
publish-date=04092015