内容


使用 Pyramid、SQLDB 和 Bluemix 构建一个聊天应用程序

使用 SQLAlchemy 在 Bluemix 中集成数据库服务

Comments

如果对 Python 拥有基本的了解,并希望从头开发 Web 应用程序,将其部署到 IBM® Bluemix® 中,那么本教程非常适合您。了解如何使用 Pyramid 框架及其安全模型进行开发,如何使用 Socket.IO 抽象进行实时通信,以及如何与 SQLAlchemy 进行集成。SQLAlchemy 提供了在 Bluemix 环境中集成数据库服务的一种简单方法。

通过系统地一次添加一个特性,可以创建 Chatter 聊天应用程序,用户可以在其中实时交换消息。消息被存储在数据库中。在登录后,用户看到的是应用程序中交换的最后 10 条消息。登录是为了进行身份识别 — 没有密码,但该方法适用于典型的身份验证和授权需求。

构建您的应用程序需要做的准备工作

运行应用程序获取代码

第 1 步. 安装 Pyramid 和 CF 客户端

对于 Ubuntu/Debian 安装:

  1. 下载 CF 客户端 作为 Debian 的 Stable Installer。
  2. 以 root 用户身份,安装 CF 客户端:dpkg -i cf-cli_*.deb
  3. 以 root 用户身份,安装 Python 包管理器、开发环境和虚拟环境:apt-get install python-pip python-dev python-virtualenv
  4. 以普通用户身份,设置一个个人虚拟环境:virtualenv $HOME/venv
  5. 激活您的虚拟环境:.$HOME/venv/bin/activate

    找到 activate 文件,确保会从虚拟环境使用 Python 和 pip。

  6. 安装 Pyramid 框架:pip install pyramid

对于 Windows® 安装:

  1. 下载适用于 Windows 的稳定 CF 客户端
  2. 解压并运行 cf_installer.exe 文件。
  3. 下载适合您平台的 Python MSI 安装程序
  4. 安装下载的文件后,在第一页上选择 Install just for me 并在 Customize Python 页面上添加 Add python.exe to Path
  5. 下载并安装 MS Visual C++ Compiler for Python 2.7
  6. 打开一个 MS-DOS 提示符并安装 Pyramid 框架:pip install 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-callable 方法
chatter/static 目录包含应用程序使用的静态资源
chatter/templates 目录包含应用程序使用的页面模板

该项目使用了对自身的包引用。要让这些引用正常工作,可以在开发模式下安装它,这还会安装 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=3600 参数更改为 cache_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

此代码声明了一个访问表 'messages'(由 __tablename__ 属性表示)的类,该表包含 3 列:id (primary key, Integer type)、text (Text type) 和 created (DateTime type)。该类扩展了 Base 基类(将在 chatter/model/meta.py 文件中进行声明),将它与 SQLAlchemy 框架集成,并将它添加到 Base 实例所持有的元数据中。Sequence() 调用让 id 列拥有自动递增的值。

created 属性使用了创建该实例时的默认值。我使用 datetime.datetime.utcnow 存储一个与时区独立的时间值。另请注意,它是一个函数的引用,而不是对该函数的调用。如果通过将它更改为 datetime.datetime.utcnow() 来改变为对函数的调用,那么默认值将是初始化 Message 类时声明的时间,而不是每个 Message 实例的时间。

在删除 MyModel 后删除 Chatter.sqlite。

添加消息 view callable

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 callable 将与路由 'home' 关联,后者默认情况下将与应用程序的 / URL 关联。它使用 chatter/model.py 文件中声明的 Message 类来获取数据库中最后 10 条消息,并将它们作为 'message' 参数返回给渲染模板 templates/messages.pt。

send callable 将与 'send' 路由(将在下一步中添加)关联。它处理从 templates/messages.pt 文件中的表单提交的请求,处理方法是:使用 DBSessionMessage 类插入一个新行,并将请求重定向回 '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 callable 传递给它的所有消息列表,以及一个将消息发送到 'send' 路由的表单。tal:repeat="m messages" 属性是 Chameleon 为数组 'messages' 中的每个项创建 <li> 元素的方式(使用每个项 'm' 变量)。tal:content="m.text" 属性告诉模板引擎,将 "m.text" 的值放在该元素中(在 <li> 和 </li> 之间)。

要让文本看起来更像一条交换的消息,而不是一个无序的项目列表,可以创建包含以下内容的 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 更加动态

有多个用户发送消息时,该应用程序目前不是特别有用,因为每个用户仅在发送自己的消息时才会看到其他消息。我们让所有连接到服务器的用户都能立即看到消息。需要添加对 SocketIO 抽象(最初是为 NodeJS 开发的)的支持。

创建启动程序脚本

首先,调整应用程序的启动方式。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 的包。将 gevent-socketio, 添加到 setup.py 文件中的 requires[] 列表中,再次运行 python setup.py develop

此脚本使用 SocketIOServer 实现而不是默认的 WSGIServer 来监听请求。现在运行 python serve.py 来测试该脚本,看看应用程序是否仍具有相同的行为方式。

更新 SocketIO 的服务器端

要更改 send callable 处理传入的请求的方式,可将 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)

现在将以一种特殊的 SocketIO 方式处理提交到 send 路由的请求,并将 /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 通信的服务器端。对于客户端,您需要调整模板,添加 JavaScript 来处理 SocketIO 交换。

将 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>

我在 JavaScript 代码的提交表单中添加了 id 来查找它,还添加了一组 JavaScript 文件。jquery.min.js 用于简化在页面上查找元素的方式。

备注:我使用了 socket.io.js 0.9.16 版,因为 gevent-socketio 实现支持或兼容该版本。1.0 版的 socket.io.js 引入了一些与以前的 SocketIO 版本不兼容的更改。

chat.js 脚本为应用程序处理 SocketIO,所以在 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’ 名称空间(ChatNamespace 所映射的 send view callable 中的名称空间)。
  • 监听聊天事件(由 on_chat 方法中的 ChatNameserver 广播),以便将包含收到的数据的 '<li>' 元素附加到 chatlog 中。
  • 提交 chatform 时发出聊天事件(由 on_chat 方法中的 ChatNameserver 处理)。

如果现在使用 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 callable。
  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' 权限,禁止其他所有人的访问。添加了 groupfinder 函数来获取 userid 所属的组的列表。应用程序中的所有用户都属于 '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 类现在设置为应用程序的根上下文。该代码还设置了使用 auth_tkt cookie 来保存身份验证信息的策略(使用 AuthTktAuthenticationPolicy 实例),该信息使用 'chattersecret' 为关键字(应对每个应用程序惟一)来加密并使用 'sha512' 算法来执行亚希运算。它使用 groupfinder 方法作为回调,查找使用身份验证信息的用户所属的组。该代码使用 ACL 作为授权策略,这意味着对上下文对象的访问都被指定为 '__acl__' 类属性。

如果现在尝试运行应用程序,则会获得 403 Forbidden 错误,因为您没有为用户提供验证自身的方式。您需要将 forbidden_view 添加到 chatter/views.py 中。将以下行添加到该文件的末尾处。

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 callable,而不是返回默认的 forbidden 错误页面。同样的 callable 在提交登录页面时也会使用。'login'(login.pt 中的提交按钮的名称)用于区分这两种事件。就目前而言,我们只需确保所提供的用户名不是一个空字符串。如果满足此条件,就会存储用户信息,并将回调重定向到 'home'。否则,它返回到登录页面并抛出一条错误消息。

logout callable 允许用户 “忘记” 请求。

将登录页面的模板添加到 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" 表示这个 span 元素仅在 error 值为空的时候添加。该元素的内容为 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

请注意,我们添加了 userid 属性作为 'users.id' 列的 ForeignKey,这可以通过 User 类使用的表名称来表示。User 类的 messages 属性声明中使用了相同的关系。对 relation(Message) 的调用创建了一个查询属性,它返回了一组 Message 实例消息,这些消息的外键与 User 的主键相匹配。backref="user" 选项导致在 Message 类上添加了一个 user 属性,返回该外键所引用的 User 实例。

数据库模型已更改,所以重新创建数据库。删除旧数据库(Chatter.sqlite 文件),它将在下次运行应用程序时使用合适的模型来重新创建。

要调整 loginmessages callable 来考虑用户信息并避免用户重复,可将 userid 而不是 username 存储在请求中。在 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() 的调用,以便将添加用户的请求传递给数据库来创建新用户 ID,从而可以从 user 实例检索它。

接下来应该更改处理 chat 事件的代码,因为 Message 实例现在需要指定用户。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)

还需要使用 logout 提交按钮的以下代码来更新 chatter/templates/messages.pt:

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

现在应用程序会像以前一样运行,但存储了一个用户与一条消息之间的关联。

在 Git 存储库中使用标记 step4b-user-info 来查看目前为止的代码。

向聊天事件中添加用户和时间数据

在用户与消息之间建立关联的全部要点是将它们包含在输出中。我们调整了 SocketIO 请求,让此信息可供浏览器访问。

您拥有一种比传递给客户端的简单字符串更复杂的结构,所以我们还将在客户端上对最初的消息进行格式化。为了适应这种情况并避免代码重复,请将 chat 请求更改为始终发送一个消息对象数组,而不只是一个对象。将 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 查看目前为止的代码。

添加 “who's on” 信息

在大多数聊天应用程序中,用户可以看到对话的参与者。要在应用程序中实现此目标,可以添加新的表类 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

此表包含用户的每个连接和断开事件的记录。因为模型中的现有表的声明没有发生更改,所以不需要删除 Chatter.sqlite。这个新表会在下次运行应用程序时添加到现有表中。

用户状态信息在一个 status 事件中发送给客户端。事件数据包含一个用户数组和它们当前的状态:'on''off'。通过 recv_disconnect 方法添加 connect 事件的处理,更新 chatter/views.py 中的 recv_disconnecton_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])

澄清一点:

  • 发生 connectdisconnect 事件时,会向 Connect 表添加一条新记录,并将 status 事件广播给所有用户。
  • 从客户收到 history 请求时,会使用 status 事件和 chat 事件传入目前连接的用户列表。
  • 对于 connectedUserslastIdQuery 返回每个用户的最高的 Connect 记录中的 ID。connQuery 从 Connect 表中返回与这些 ID 对应的记录(每个用户的最后一条记录)。connectedUsers 向用户返回 Connect 表中最后一条记录中的 isonTrue

为了预防同一个用户同时连接多次,可在相同的 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;
}

向 chatter/static/chat.js 添加 status 事件的处理,以便根据用户的 ison 状态来添加和删除用户:

  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=0 现在可更改回 cache_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

这告诉 CF 客户端使用名称 TestChatter 推送该应用程序,使用 128M、Python 构建基块并使用 serve.py 作为启动脚本。

要确保未通过该应用程序将不必要的文件推送给 Bluemix,可以创建一个包含以下代码的 .cfignore 文件:

Chatter.sqlite
dist

处理 VCAP_APP_PORT

要让应用程序在 Bluemix 中运行,应在正确的端口上监听它。将 port 参数添加到 serve.py 中的 serve 调用中:

        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 没有开源客户端,所以直接向应用程序添加必要的库。从 Fix Packs for IBM Data Server Client Packages 下载适用于 Linux/x86-64 64 位版本的 Data Server Driver Package,并将它放在应用程序的根目录下。即使在不同的平台上开发此应用程序,也可以使用 Linux/x86-64 下载版本,因为该应用程序将在 Bluemix 中的平台上运行。该驱动程序的 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]])

请注意:

  • 只有在该操作系统确实是您的驱动程序所针对的操作系统时,才会执行安装。仅在之前未执行文件的提取的情况下才会执行文件的提取。
  • -N 选项告诉 easy_install 不要在网络上检查新包。
  • 在使用 glob 时,代码将会处理包的所有版本。
  • 将驱动程序的安装和 Python 包的安装拆分到两个单独的 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 的应用程序的基本知识。

目前的示例应用程序包含无法从接口访问的有用信息。可将此视为使用本教程中获得的技能来进一步扩展应用程序的起点。一些想法:允许滚动浏览所有存储的消息,添加消息搜索,或者以每天的访问的用户数量的形式显示应用程序的流行度。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Cloud computing
ArticleID=1003587
ArticleTitle=使用 Pyramid、SQLDB 和 Bluemix 构建一个聊天应用程序
publish-date=04152015