Django2 和 Channel2 实践教程(四)
八、移动客服应用的后端
在这一章中,我们将离开 HTML 和普通的 HTTP。我们将创建必要的基础设施来支持移动应用,包括使用 WebSocket 和 HTTP 服务器端事件的异步通信。
本章以 Django 频道为中心,包括以下主题:
-
如何整合
-
如何构建 WebSocket 消费者
-
如何将其与 Redis 整合
-
如何使用它来异步提供内容
Django 频道
Django Channels 是 Django 生态系统的新成员。它得到了 Django 的官方支持,所有的开发都是和 Django 一起在 GitHub 上进行的。但是,在安装 Django 时不包括它。
Channels 允许我们编写异步代码来处理传入的请求,这在客户端和服务器之间有自然异步交互的情况下很有帮助——例如,聊天会话。
虽然如果实时组件不是关键的话,在技术上可以构建一个具有普通 Django 视图的聊天服务器,但是当客户端数量增加时,同步系统将无法像异步系统那样扩展。
也就是说,在同步系统上开发要容易得多。不要认为异步编程总是一个更好的范例,因为,和计算中的任何事情一样,它也有代价。
异步代码与同步代码
同步编程是一个非常好理解的执行模型。你的代码被从头到尾执行,然后将控制权返回给 Django。另一方面,在异步编程中,情况并非总是如此。您的异步代码与一个asyncio事件循环协同工作。网上有很多文档,我鼓励你去看看,比如 1 。
幸运的是,在使用 Django 通道之前,您不需要理解异步编程的所有内容。这个库隐藏了一些底层细节,简化了您的工作。要理解的最重要的事情之一是,您不能自由地混合同步和异步代码。每次这样做,你都需要跨越一个边界 2 。
我鼓励你花一些时间在线阅读关于异步编程的内容。它将帮助你理解本章中的代码和概念。
安装和配置
为了在我们的系统上安装通道,我们将把它和 Redis 一起安装,Redis 是一个开源的内存数据结构服务器( https://redis.io )。这与我们一开始使用 PostgreSQL 的原因相同:总是在生产部署将使用的相同环境中工作。
继续下载并在您的操作系统上安装 Redis。完成后,继续安装通道:
pipenv install channels
pipenv install channels_redis
我们的项目需要一个新文件routing.py,放在同一个文件夹settings.py中。目前,这个文件除了内置的路由之外,不会声明任何路由。
booktime/routing.py的内容是:
from channels.routing import ProtocolTypeRouter
application = ProtocolTypeRouter({})
我们将使用 Redis 将该文件与通道配置连接起来。应用channels需要成为第一个,因为它覆盖了标准 Django 的runserver命令:
INSTALLED_APPS = [
'channels',
...
]
ASGI_APPLICATION = "booktime.routing.application"
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
},
},
}
您现在可以使用这个库了。这是新的runserver命令的输出:
$ ./manage.py runserver
Performing system checks...
System check identified no issues (0 silenced).
August 15, 2018 - 18:56:30
Django version 2.1, using settings 'booktime.settings' Starting ASGI/Channels version 2.1.2 development server at...
Quit the server with CONTROL-C.
2018-08-15 18:56:30,270 - INFO - server - HTTP/2 support not enabled
2018-08-15 18:56:30,271 - INFO - server - Configuring endpoint...
2018-08-15 18:56:30,272 - INFO - server - Listening on 127.0.0.1:8000
用缰绳
如前所述,Redis 是一个内存数据结构服务器。它可以用作非常简单的数据库、缓存或消息代理。它有一组与普通数据库非常不同的操作,它的用例也非常不同。
在我们的设置中,通道将使用 Redis 在运行的 Django 应用的不同实例之间传递消息。Redis 还将使我们能够在不同机器上运行的实例之间以及在单个服务器上运行的实例之间传递消息。
除了要求 Redis 支持进程间的通信,本章的代码将直接使用 Redis 创建一个简单的存在机制。默认情况下,通道不提供确定某些用户是否连接到我们的端点的方法。
顾客
我们可以把渠道中的消费者看作是基于类别的视图的等价物,区别在于消费者不像 cbv 那样层次高、数量多。有两个基本的消费者类别,SyncConsumer和AsyncConsumer。除了异步代码依赖于 Python async/await语法之外,它们的接口是相同的。
消费者的结构基于使用类方法和send()内置方法的消息处理程序的组合。消费者可以有多个消息处理程序,消息的路由基于消息的type值。
当类初始化时,每个消费者都将收到一个存储在self.scope中的scope对象。它包含关于初始请求的信息,例如:
-
scope["path"]:请求的路径 -
scope["user"]:当前用户(仅在认证启用时) -
scope["url_route"]:包含匹配的路由(如果使用 URL 路由器)
原始消费者有几个子类:
-
WebsocketConsumer和AsyncWebsocketConsumer -
JsonWebsocketConsumer和AsyncJsonWebsocketConsumer -
AsyncHttpConsumer
这些子类中的每一个都添加了一些 helper 方法,例如,管理 JSON 编码/解码或 WebSockets。
频道、频道层和群组
这个库的核心概念之一是通道。通道本质上是一个邮箱,当它作为请求处理的一部分被实例化时,每个使用者会自动获得一个邮箱,当使用者终止时,它会被删除。这就是消息从外部发送给给定消费者的方式。
通道层相当于邮递员。它们需要在 Django 的不同实例之间传输消息。该层可以将消息传递给单个消费者(通过知道其通道名)或一组消费者(通过知道其组通道名)。
向单个消费者发送消息并不是常见的用例。更常见的是向与特定用户相关的所有消费者发送消息(在他或她在多个选项卡上打开站点的情况下很有用),或者向一组特定用户的所有消费者发送消息(就像我们的聊天案例)。
为此,Channels 提供了一个名为组的抽象。群组是您可以向其发送消息的实体,这些消息将被转发到与其连接的所有消费者渠道。通过通道层上的方法group_add()和group_discard()将通道连接到组。
要向群组发送信息,您可以使用group_send()。当通过此方法发送消息时,通道层会将消息转发到所有连接的消费者通道。然后,消费者将通过调用特定消息类型的消息处理程序来自动处理消息。
然后,处理程序负责将消息转发回 HTTP 客户端(通过使用send())或进行所需的计算。
路由和中间件
通道包括各种路由器,用于将请求路由到特定的消费者。路由可以基于协议(HTTP 或 WebSocket)、URL 或通道名称。
在我们的项目中,我们最初将使用ProtocolTypeRouter,因为我们需要将 WebSocket 处理代码与普通的 Django 视图分开。我们将使用路由器和渠道提供的特殊中间件。
这里的中间件不同于标准 Django 中的中间件。这个中间件是完全异步的。它提供了一些原则上类似于 Django 中间件的东西:过滤、阻止和向作用域添加附加信息。
在我们的应用中,我们将使用AuthMiddlewareStack,它是认证、会话和 cookie 中间件组件的组合。这个中间件堆栈将负责加载用户会话,确定连接是否经过身份验证,如果是,则加载消费者范围内的用户对象。
为我们的客服人员聊天
让我们开始用渠道构建一些具体的东西:客服人员的内部聊天页面。我们将从下载一个必备软件reconnecting-websocket开始,它将为我们处理不稳定的连接:
$ curl -o main/static/js/reconnecting-websocket.min.js \
https://raw.githubusercontent.com/joewalnes/reconnecting-websocket/
\master/reconnecting-websocket.min.js
我们将为我们的客服代表提供一个非常简单的聊天页面,您可以随意设计。这是main/templates/chat_room.html的内容:
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Chat Room</title>
<script
src="{% static "js/reconnecting-websocket.min.js" %}"
charset="utf-8"></script>
</head>
<body>
<textarea id="chat-log" cols="100" rows="20"></textarea><br/>
<input id="chat-message-input" type="text" size="100"/><br/>
<input id="chat-message-submit" type="button" value="Send"/>
</body>
<script>
var roomName = {{ room_name_json }};
var chatSocket = new ReconnectingWebSocket(
'ws://' + window.location.host + '/ws/customer-service/' +
roomName + '/'
);
chatSocket.onmessage = function (e) {
var data = JSON.parse(e.data);
var username = data['username'];
if (data['type'] == "chat_join") {
message = (username + ' joined\n ');
} else if (data['type'] == "chat_leave") {
message = (username + ' left\n ');
} else {
message = (username + ': ' + data['message'] + '\n');
}
document
.querySelector('#chat-log')
.value += message;
};
chatSocket.onclose = function (e) {
console.error('Chat socket closed unexpectedly');
};
document
.querySelector('#chat-message-input')
.focus();
document
.querySelector('#chat-message-input')
.onkeyup = function (e) {
if (e.keyCode === 13) { // enter, return
document
.querySelector('#chat-message-submit')
.click();
}
};
document
.querySelector('#chat-message-submit')
.onclick = function (e) {
var messageInputDom = document.querySelector(
'#chat-message-input'
);
var message = messageInputDom.value;
chatSocket.send(
JSON.stringify({'type': 'message', 'message': message})
);
messageInputDom.value = ";
};
setInterval(function () {
chatSocket.send(JSON.stringify({'type': 'heartbeat'}));
}, 10000);
</script>
</html>
这是我们定制的包含在渠道库中的示例版本。它是一个简单的消息可视化器/发送器,通过 WebSocket 连接发送和接收 JSON 消息。
WebSocket 协议格式
我们将为 WebSocket 消息定义一个非常简单的格式,服务器到客户端:
{
type: "TYPE",
username: "who is the originator of the event",
message: "This is the displayed message" (optional)
}
这里,TYPE可以有以下值:
-
chat_join:用户名加入了聊天。 -
chat_leave:用户名离开了聊天。 -
chat_message:用户名发送消息。
这足以定义服务器到客户端。现在,对于客户端到服务器:
{
type: "TYPE",
message: "This is the displayed message" (optional)
}
TYPE在这种情况下可以有以下值:
-
message:用户名发送消息。 -
heartbeat:ping 命令,让服务器知道用户是活动的。
这描述了我们的整个 WebSocket 协议。我们将使用它来为我们公司的客户构建客服部分和移动界面。
Redis 中的心跳机制
我们的聊天需要一个以用户为中心而不是以连接为中心的在线系统。任何用户,无论是客服代表还是最终用户,一旦他们发起的所有 WebSocket 连接被关闭或处于非活动状态超过 10 秒钟,他们都将变得不可用。这将确保以更可靠的方式处理网络问题或浏览器崩溃。
我们将依赖 Redis 的到期功能。Redis 有一种非常有效的方法来设置特定键的值,只是暂时的。当设置值和过期时间时,Redis 会在适当的时候自动删除它们。对我们来说,这是一个完美的机制,因为它可以自动回收密钥。
来自客户端的心跳信号将在名为 customer-service _ ORDERID _ user email 的键上发出 Redis SETEX命令,设置虚拟值 1,10 秒后到期。
这足以支持一个页面来显示谁连接到哪个客服聊天的动态列表。该页面将发出带有前缀customer-service_的 Redis KEYS命令,并从返回的结果中生成信息。
为了直接使用 Redis,我们需要向我们的系统添加一个新的依赖项:
$ pipenv install aioredis
假设我们使用异步消费者,我们将需要异步网络库。aioredis库是 Redis Python 客户端的异步版本。
引导页面
我们将添加一个到main/urls.py的 URL 和一个到main/views.py的视图,以服务前面给出的chat_room.html模板。让我们从视图开始:
...
def room(request, order_id):
return render(
request,
"chat_room.html",
{"room_name_json": str(order_id)},
)
...
urlpatterns = [
...
path(
"customer-service/<int:order_id>/",
views.room,
name="cs_chat",
),
]
聊天引导页面现在已经完成,但是它的 JavaScript 不能工作,因为 WebSocket 端点还不存在。
WebSocket 消费者
消费者是大部分工作发生的地方。我们将把消费者放在main/consumers.py:
import aioredis
import logging
from django.shortcuts import get_object_or_404
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from . import models
logger = logging.getLogger(__name__)
class ChatConsumer(AsyncJsonWebsocketConsumer):
EMPLOYEE = 2
CLIENT = 1
def get_user_type(self, user, order_id):
order = get_object_or_404(models.Order, pk=order_id)
if user.is_employee:
order.last_spoken_to = user order.save()
return ChatConsumer.EMPLOYEE
elif order.user == user:
return ChatConsumer.CLIENT
else:
return None
async def connect(self):
self.order_id = self.scope["url_route"]["kwargs"][
"order_id"
]
self.room_group_name = (
"customer-service_%s" % self.order_id
)
authorized = False
if self.scope["user"].is_anonymous:
await self.close()
user_type = await database_sync_to_async(
self.get_user_type
)(self.scope["user"], self.order_id)
if user_type == ChatConsumer.EMPLOYEE:
logger.info(
"Opening chat stream for employee %s",
self.scope["user"],
)
authorized = True
elif user_type == ChatConsumer.CLIENT:
logger.info(
"Opening chat stream for client %s",
self.scope["user"],
)
authorized = True
else:
logger.info(
"Unauthorized connection from %s",
self.scope["user"],
)
await self.close()
if authorized:
self.r_conn = await aioredis.create_redis(
"redis://localhost"
)
await self.channel_layer.group_add(
self.room_group_name, self.channel_name
)
await self.accept()
await self.channel_layer.group_send(
self.room_group_name,
{
"type": "chat_join",
"username": self.scope[
"user"
].get_full_name(),
},
)
async def disconnect(self, close_code):
if not self.scope["user"].is_anonymous:
await self.channel_layer.group_send(
self.room_group_name,
{
"type": "chat_leave",
"username": self.scope[
"user"
].get_full_name(),
},
)
logger.info(
"Closing chat stream for user %s",
self.scope["user"],
)
await self.channel_layer.group_discard(
self.room_group_name, self.channel_name
)
async def receive_json(self, content):
typ = content.get("type")
if typ == "message":
await self.channel_layer.group_send(
self.room_group_name,
{
"type": "chat_message",
"username": self.scope[
"user"
].get_full_name(),
"message": content["message"],
},
)
elif typ == "heartbeat":
await self.r_conn.setex(
"%s_%s"
% (
self.room_group_name,
self.scope["user"].email,
),
10, # expiration (in 10 seconds)
"1", # dummy value
)
async def chat_message(self, event):
await self.send_json(event)
async def chat_join(self, event):
await self.send_json(event)
async def chat_leave(self, event):
await self.send_json(event)
我们从AsyncJsonWebsocketConsumer派生出我们的消费者,它负责WebSocket的底层方面和 JSON 编码。我们需要实现receive_json()、connect()和disconnect()来让这个类工作。
connect()做的第一件事是生成一个房间名,它将被用作group_*()呼叫的通道名。在此之后,我们需要确保用户有权限在这里,这需要访问数据库。
从异步消费者访问数据库需要将代码包装在同步函数中,然后使用database_sync_to_async()方法。这是因为 Django 本身,尤其是 ORM,是以同步方式编写的。
前面代码中的get_user_type()方法除了检查用户类型之外,还存储订单中客户与之交谈的最后一个雇员的姓名。
三种主要的方法,receive_json()、connect()和disconnect(),使用通道层方法group_send()、group_add()和group_discard()来管理一个聊天室的所有不同消费者实例之间的通信和同步。
在方法receive_json()中,我们使用group_send()方法来处理我们在 WebSocket 协议格式部分列出的两种类型的消息。“message”类型的消息按原样转发给所有连接的消费者,而“heartbeat”类型的消息用于更新 Redis 密钥的到期时间(如果该密钥尚不存在,则创建该密钥)。
在方法connect()和disconnect()中,我们使用group_send()方法来生成不同用户的加入/离开消息。
记住group_send()不是将数据发送回浏览器的 WebSocket 连接,这一点很重要。它仅用于使用配置的通道层在消费者之间传递信息。每个消费者将通过消息处理程序接收这些数据。
最后,在 ChatConsumer 中有三个处理程序:chat_message()、chat_join()和chat_leave()。它们都将消息直接发送回浏览器的 WebSocket 连接,因为所有的处理都将发生在前端。
将处理消息的消息处理程序的名称来自 type 字段。如果用chat_message的message[ ' type ' ]调用group_send(),则接收消费者将使用chat_message()处理程序处理此事。
根据最后几段,花点时间重读代码,并交叉引用我刚才提到的关于代码功能的内容。阅读代码时还要考虑到,异步编程不是 Python 中的默认风格。
这个消费者需要我们数据库模式中的一个新字段。让我们快速添加到我们的main/models.py:
...
class Order(models.Model):
...
last_spoken_to = models.ForeignKey(
User,
null=True,
related_name="cs_chats",
on_delete=models.SET_NULL,
)
...
添加之后,不要忘记运行管理命令makemigrations和migrate来将其应用到数据库。
在定义了消费者及其所有需求之后,我们将继续为这个消费者定义路由。
选择途径
目前我们在booktime/routing.py中有一个空的{}路由变量。我们需要改变这种情况。
我们将在main/routing.py管理所有特定于我们站点的路线:
from django.urls import path
from . import consumers
websocket_urlpatterns = [
path(
"ws/customer-service/<int:order_id>/",
consumers.ChatConsumer
)
]
我们将管理booktime/routing.py中的所有常规路线:
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
import main.routing
application = ProtocolTypeRouter({
# (http->django views is added by default)
'websocket': AuthMiddlewareStack(
URLRouter(
main.routing.websocket_urlpatterns
)
),
})
这个设置将为我们的 WebSocket 消费者分配一个 URL 路径,并将 WebSocket 流量中继到这个新的“websocket”路由。如果我们不将“http”协议类型添加到主路由器,通道将自动添加它以支持标准 Django 视图。
现在,您可以通过在浏览器中加载聊天 URL 来测试您的页面。您应该能够加载聊天,并在聊天中键入一些句子。如果您在多个浏览器中加载聊天内容,您会看到您在一个窗口中输入的内容会出现在另一个窗口中。
自动化测试
正如本书中的任何内容一样,我将解释如何测试到目前为止公开的代码。Channels 提供了名为communicator的构造。您可以将通信器视为 Django 测试用例中包含的测试客户端的等价物。
与消费者不同,通信者没有同步版本和异步版本。它们只带有一个异步 API。要将它与同步运行的标准 Python Unittest 框架一起使用,我们需要使用一些低级别的 asyncio API。这就是我们在测试中要做的。
我们将把这些测试放在main/tests/test_consumers.py中:
import asyncio
from django.contrib.auth.models import Group
from django.test import TestCase
from channels.db import database_sync_to_async
from channels.testing import WebsocketCommunicator
from main import consumers
from main import factories
class TestConsumers(TestCase):
def test_chat_between_two_users_works(self):
def init_db():
user = factories.UserFactory(
email="john@bestemails.com",
first_name="John",
last_name="Smith",
)
order = factories.OrderFactory(user=user)
cs_user = factories.UserFactory(
email="customerservice@booktime.domain",
first_name="Adam",
last_name="Ford",
is_staff=True,
)
employees, _ = Group.objects.get_or_create(
name="Employees"
)
cs_user.groups.add(employees)
return user, order, cs_user
async def test_body():
user, order, cs_user = await database_sync_to_async(
init_db
)()
communicator = WebsocketCommunicator(
consumers.ChatConsumer,
"/ws/customer-service/%d/" % order.id,
)
communicator.scope["user"] = user
communicator.scope["url_route"] = {
"kwargs": {"order_id": order.id}
}
connected, _ = await communicator.connect()
self.assertTrue(connected)
cs_communicator = WebsocketCommunicator(
consumers.ChatConsumer,
"/ws/customer-service/%d/" % order.id,
)
cs_communicator.scope["user"] = cs_user
cs_communicator.scope["url_route"] = {
"kwargs": {"order_id": order.id}
}
connected, _ = await cs_communicator.connect()
self.assertTrue(connected)
await communicator.send_json_to(
{
"type": "message",
"message": "hello customer service",
}
)
await asyncio.sleep(1)
await cs_communicator.send_json_to(
{"type": "message", "message": "hello user"}
)
self.assertEquals(
await communicator.receive_json_from(),
{"type": "chat_join", "username": "John Smith"},
)
self.assertEquals(
await communicator.receive_json_from(),
{"type": "chat_join", "username": "Adam Ford"},
)
self.assertEquals(
await communicator.receive_json_from(),
{
"type": "chat_message",
"username": "John Smith",
"message": "hello customer service",
},
)
self.assertEquals(
await communicator.receive_json_from(),
{
"type": "chat_message",
"username": "Adam Ford",
"message": "hello user",
},
)
await communicator.disconnect()
await cs_communicator.disconnect()
order.refresh_from_db()
self.assertEquals(order.last_spoken_to, cs_user)
loop = asyncio.get_event_loop()
loop.run_until_complete(test_body())
在这个测试中,我们测试连接是否正常工作,消息是否按照预期的方式被转发。为了测试这一点,我们使用两个通信器,一个代表客服操作员,另一个代表最终用户。
注意,前面的测试函数包含两个子函数,一个用于同步数据库初始化,另一个用于主要的异步主体。然后,通过直接引用 asyncio 循环来运行主异步体。
通信器将消费者要连接的对象和 URL 作为参数。URL 不是绝对必要的,但是 URLRouter 使用它来在使用者范围内注入路由。URLRouter 不支持命名路由,因此在引用 URL 时不能使用reverse()。
我们的消费者还需要一个阻止未授权用户的测试:
...
def test_chat_blocks_unauthorized_users(self):
def init_db():
user = factories.UserFactory(
email="john@bestemails.com",
first_name="John",
last_name="Smith",
)
order = factories.OrderFactory()
return user, order
async def test_body():
user, order = await database_sync_to_async(init_db)()
communicator = WebsocketCommunicator(
consumers.ChatConsumer,
"/ws/customer-service/%d/" % order.id,
)
communicator.scope["user"] = user
communicator.scope["url_route"] = {
"kwargs": {"order_id": order.id}
}
connected, _ = await communicator.connect()
self.assertFalse(connected)
loop = asyncio.get_event_loop()
loop.run_until_complete(test_body())
不可否认,这里的低级 asyncio 函数过于简单。如果您打算开始编写许多异步测试,您可能想看看 Pytest,这是 Django Channels 在内部发布时使用的。
聊天仪表板(带在线状态)
我们的客服代表需要能够看到是否有客户在等待服务。我们需要建立一个仪表板,动态更新聊天室和其中各种人的列表。
我们将采用与之前构建的聊天类似的方法,但是我们将使用更简单的单向方法来代替 WebSockets。我们将使用 HTTP 服务器发送的事件。
服务器发送的事件(SSE)本质上是一个 HTTP 连接,它保持打开,并在事件发生时接收大量信息。每个信息块都以单词“data:”为前缀,并以两个换行符结束。
我邀请你在线阅读一些关于协议格式 3 的文档。SSE 没有 WebSockets 复杂。它很容易实现,所有主流浏览器都支持它。
类似于我们之前所做的,我们将使用reconnecting-eventsource,它将为我们处理不稳定的连接:
$ curl -o main/static/js/reconnecting-eventsource.js \
https://cdn.jsdelivr.net/npm/reconnecting-eventsource@1.0.1/\
dist/ReconnectingEventSource.js
本着与聊天页面相同的精神,这是我们非常简单的仪表板,存储在main/templates/customer_service.html:
图 8-1
客服仪表板
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Chat Rooms</title>
<script
src="{% static "js/reconnecting-eventsource.js" %}"
charset="utf-8"></script>
</head>
<body>
<h1>Customer chats</h1>
<div id="notification-area"></div>
<script>
var source = new ReconnectingEventSource('/customer-service/notify/');
source.addEventListener('message', function (e) {
document
.getElementById("notification-area")
.innerHTML = "";
var data = JSON.parse(e.data);
var html;
for (var i = 0; i < data.length; i++) {
html = '<div><a href="' + data[i]['link'] + '">' + data[i]['text'] + '</a></div>';
document
.getElementById("notification-area")
.innerHTML += html;
}
}, false);
</script>
</body>
</html>
我们将再次添加一个到main/urls.py的 URL 来提供上面的模板:
...
urlpatterns = [
...
path(
"customer-service/",
TemplateView.as_view(
template_name="customer_service.html"
),
name="cs_main",
),
]
HTTP 服务器发送的事件消费者
上面的页面将从main/consumers.py的一个新消费者那里接收动态数据:
...
import asyncio
import json
from django.urls import reverse
from channels.exceptions import StopConsumer
from channels.generic.http import AsyncHttpConsumer
...
class ChatNotifyConsumer(AsyncHttpConsumer):
def is_employee_func(self, user):
return not user.is_anonymous and user.is_employee
async def handle(self, body):
is_employee = await database_sync_to_async(
self.is_employee_func
)(self.scope["user"])
if is_employee:
logger.info(
"Opening notify stream for user %s and params %s",
self.scope.get("user"),
self.scope.get("query_string"),
)
await self.send_headers(
headers=[
("Cache-Control", "no-cache"),
("Content-Type", "text/event-stream"),
("Transfer-Encoding", "chunked"),
]
)
self.is_streaming = True
self.no_poll = (
self.scope.get("query_string") == "nopoll"
)
asyncio.get_event_loop().create_task(self.stream())
else:
logger.info(
"Unauthorized notify stream for user %s and params %s",
self.scope.get("user"),
self.scope.get("query_string"),
)
raise StopConsumer("Unauthorized")
async def stream(self):
r_conn = await aioredis.create_redis("redis://localhost")
while self.is_streaming:
active_chats = await r_conn.keys(
"customer-service_*"
)
presences = {}
for i in active_chats:
_, order_id, user_email = i.decode("utf8").split(
"_"
)
if order_id in presences:
presences[order_id].append(user_email)
else:
presences[order_id] = [user_email]
data = []
for order_id, emails in presences.items():
data.append(
{
"link": reverse(
"cs_chat",
kwargs={"order_id": order_id}
),
"text": "%s (%s)"
% (order_id, ", ".join(emails)),
}
)
payload = "data: %s\n\n" % json.dumps(data)
logger.info(
"Broadcasting presence info to user %s",
self.scope["user"],
)
if self.no_poll:
await self.send_body(payload.encode("utf-8"))
self.is_streaming = False
else:
await self.send_body(
payload.encode("utf-8"),
more_body=self.is_streaming,
)
await asyncio.sleep(5)
async def disconnect(self):
logger.info(
"Closing notify stream for user %s",
self.scope.get("user"),
)
self.is_streaming = False
这个消费者用handle()和disconnect()方法实现了AsyncHttpConsumer接口。由于我们试图构建的端点的流性质,我们需要保持连接开放。我们将调用send_headers()方法来启动 HTTP 响应,并且我们将调用send_body(),将more_body参数设置为True,在一个独立的异步任务中保持活动状态。
stream()方法激活被添加到事件循环中,并且将保持活动状态,直到disconnect()方法被调用(客户端发起的断开连接)。当这个方法被调用时,它会将一个is_streaming标志设置为False,这将导致运行在不同 asyncio 任务中的stream()内循环退出。
stream()方法将定期从 Redis 中读取未过期的密钥,并将它们发送回客户端。如果在连接时传入了nopoll标志,它将退出循环,而不等待客户端断开连接。
只有授权用户才能进行流式传输。对于未经授权的用户,handle()方法将引发一个StopConsumer异常,该异常将停止消费者并关闭与客户端的当前连接。
选择途径
SSE 消费者是我们创建的第一个 HTTP 消费者,它需要一些额外的配置才能工作。我们将在main/routing.py中定义所有非 websocket HTTP 路由:
from channels.auth import AuthMiddlewareStack
...
http_urlpatterns = [
path(
"customer-service/notify/",
AuthMiddlewareStack(
consumers.ChatNotifyConsumer
)
)
]
我们将用一个定制的路由覆盖booktime/routing.py中的默认 HTTP 路由:
from django.urls import re_path
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.http import AsgiHandler
import main.routing
application = ProtocolTypeRouter(
{
"websocket": AuthMiddlewareStack(
URLRouter(main.routing.websocket_urlpatterns)
),
"http": URLRouter(
main.routing.http_urlpatterns
+ [re_path(r"", AsgiHandler)]
),
}
)
HTTP 路由现在与一个 URLRouter 相关联,该 URL router 包含我们在main/routing.py中定义的http_urlpatterns,对于所有其他路由,它退回到AsgiHandler,后者又转发到标准的 Django 处理程序。
AsgiHandler是从异步服务器网关接口(ASGI)协议到 Web 服务器网关接口(WSGI)协议的翻译器,WSGI 是核心 Django 使用的内部协议。
自动化测试
针对 SSE 消费者的测试将使用不同类型的通信器HttpCommunicator。我们将在建立 WebSocket 连接和连接超时后使用它,我们将检查 SSE 请求是否返回了正确的事件。超时是为了给 Redis 时间从内存中删除条目。
...
import json
from channels.testing import HttpCommunicator
class TestConsumers(TestCase):
...
def test_chat_presence_works(self):
def init_db():
user = factories.UserFactory(
email="user@site.com",
first_name="John",
last_name="Smith",
)
order = factories.OrderFactory(user=user)
cs_user = factories.UserFactory(
email="customerservice@booktime.domain",
first_name="Adam",
last_name="Ford",
is_staff=True,
)
employees, _ = Group.objects.get_or_create(
name="Employees"
)
cs_user.groups.add(employees)
return user, order, cs_user
async def test_body():
user, order, notify_user = await database_sync_to_async(
init_db
)()
communicator = WebsocketCommunicator(
consumers.ChatConsumer,
"/ws/customer-service/%d/" % order.id,
)
communicator.scope["user"] = user
communicator.scope["url_route"] = {
"kwargs": {"order_id": order.id}
}
connected, _ = await communicator.connect()
self.assertTrue(connected)
await communicator.send_json_to(
{"type": "heartbeat"}
)
await communicator.disconnect()
communicator = HttpCommunicator(
consumers.ChatNotifyConsumer,
"GET",
"/customer-service/notify/",
)
communicator.scope["user"] = notify_user
communicator.scope["query_string"] = "nopoll"
response = await communicator.get_response()
self.assertTrue(
response["body"].startswith(b"data: ")
)
payload = response["body"][6:]
data = json.loads(payload.decode("utf8"))
self.assertEquals(
data,
[
{
"link": "/customer-service/%d/" % order.id,
"text": "%d (user@site.com)" % order.id,
}
],
"expecting someone in the room but no one found",
)
await asyncio.sleep(10)
communicator = HttpCommunicator(
consumers.ChatNotifyConsumer,
"GET",
"/customer-service/notify/",
)
communicator.scope["user"] = notify_user
communicator.scope["query_string"] = "nopoll"
response = await communicator.get_response()
self.assertTrue(
response["body"].startswith(b"data: ")
)
payload = response["body"][6:]
data = json.loads(payload.decode("utf8"))
self.assertEquals(
data,
[],
"expecting no one in the room but someone found",
)
loop = asyncio.get_event_loop()
loop.run_until_complete(test_body())
移动 API
在本章的剩余部分,我们将停止使用 Django 通道。我们计划在移动应用中使用的其他 API 将使用 Django REST 框架(DRF)构建。我们将为订单检索构建一个身份验证端点和一个 API。
证明
对于我们的移动应用,我们将使用基于令牌的身份验证。这是非 web 应用的最佳实践,因为它为我们提供了更多的安全性,以防客户端设备受到威胁。除了令牌之外,设备上不存储任何凭据,如果需要,令牌很容易失效。
为此,我们需要在 Django Rest 框架中启用它:
INSTALLED_APPS = [
...
"rest_framework",
"rest_framework.authtoken",
...
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.BasicAuthentication",
),
...
}
这将为我们的系统增加一个额外的模型。前一步需要运行migrate命令。此后,我们将在main/signals.py中为每个有新信号的新用户自动生成一个令牌:
from django.conf import settings
from rest_framework.authtoken.models import Token
...
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_auth_token(
sender, instance=None, created=False, **kwargs
):
if created:
Token.objects.create(user=instance)
从现在开始,除了已经存在的方法之外,每个新用户都可以使用令牌访问经过身份验证的 DRF 端点。接下来我们需要创建登录端点,我们可以将它添加到我们的main/urls.py的底部:
from rest_framework.authtoken import views as authtoken_views
...
urlpatterns = [
...
path(
"mobile-api/auth/",
authtoken_views.obtain_auth_token,
name="mobile_token",
),
]
就是这样。我们有一个工作的移动认证端点。为了完成这项工作,需要进行相关的测试(main/tests/test_endpoints.py):
from django.urls import reverse
from rest_framework.test import APITestCase
from main import models
class TestEndpoints(APITestCase):
def test_mobile_login_works(self):
user = models.User.objects.create_user(
"user1", "abcabcabc"
)
response = self.client.post(
reverse("mobile_token"),
{"username": "user1", "password": "abcabcabc"},
)
jsonresp = response.json()
self.assertIn("token", jsonresp)
检索订单
我们的移动应用需要一种方法来检索当前已验证用户的订单。我们将向我们的main/endpoints.py添加一个新的端点:
from rest_framework.decorators import (
api_view,
permission_classes,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
...
@api_view()
@permission_classes((IsAuthenticated,))
def my_orders(request):
user = request.user
orders = models.Order.objects.filter(user=user).order_by(
"-date_added"
)
data = []
for order in orders:
data.append(
{
"id": order.id,
"image": order.mobile_thumb_url,
"summary": order.summary,
"price": order.total_price,
}
)
return Response(data)
这是一个定义为函数的 Django Rest 框架 API。正如 Django 一样,DRF 允许我们将端点定义为类或函数。因为这是一个自定义的只读端点,所以将其定义为函数比定义为类更容易。
这个视图使用了Order模型的一些新属性:
class Order(models.Model):
...
@property
def mobile_thumb_url(self):
products = [i.product for i in self.lines.all()]
if products:
img = products[0].productimage_set.first()
if img:
return img.thumbnail.url
@property
def summary(self):
product_counts = self.lines.values(
"product__name"
).annotate(c=Count("product__name"))
pieces = []
for pc in product_counts:
pieces.append(
"%s x %s" % (pc["c"], pc["product__name"])
)
return ", ".join(pieces)
@property
def total_price(self):
res = self.lines.aggregate(
total_price=Sum("product__price")
)
return res["total_price"]
最后,视图需要一个位于main/urls.py中的 URL:
urlpatterns = [
...
path(
"mobile-api/my-orders/",
endpoints.my_orders,
name="mobile_my_orders",
),
]
这足以让这个视图工作。我们将为main/test/test_endpoints.py添加特定的测试,以及之前的测试:
...
from rest_framework import status
from rest_framework.authtoken.models import Token
from main import factories
class TestEndpoints(APITestCase):
...
def test_mobile_flow(self):
user = factories.UserFactory(email="mobileuser@site.com")
token = Token.objects.get(user=user)
self.client.credentials(
HTTP_AUTHORIZATION="Token " + token.key
)
orders = factories.OrderFactory.create_batch(
2, user=user
)
a = factories.ProductFactory(
name="The book of A", active=True, price=12.00
)
b = factories.ProductFactory(
name="The B Book", active=True, price=14.00
)
factories.OrderLineFactory.create_batch(
2, order=orders[0], product=a
)
factories.OrderLineFactory.create_batch(
2, order=orders[1], product=b
)
response = self.client.get(reverse("mobile_my_orders"))
self.assertEqual(
response.status_code, status.HTTP_200_OK
)
expected = [
{
"id": orders[1].id,
"image": None,
"price": 28.0,
"summary": "2 x The B Book",
},
{
"id": orders[0].id,
"image": None,
"price": 24.0,
"summary": "2 x The book of A",
},
]
self.assertEqual(response.json(), expected)
订单发货跟踪
BookTime 依靠外部公司运送网上购买的商品。这家公司有自己的跟踪系统,我们希望利用它将这些额外的信息反馈给我们的移动应用用户。
我们将添加一个自定义端点来获取订单的发货状态。让我们假设快递公司已经为 BookTime 提供了对 HTTP API 的访问,我们可以使用该 API 来获取关于货物状态的实时信息,这就是这些信息的来源。
送货公司的 API 不会被移动应用直接使用,因为我们可能希望在未来添加其他公司,而不必更改移动应用代码。
这就是我们的系统要做的:
-
接收移动应用对运输信息的请求
-
向第三方公司发出 API 请求
-
将货件信息转发回手机应用
简单地说,它看起来像一个反向代理系统。
鉴于通道的异步特性,这是一个很好的用例。以异步方式这样做将使我们的解决方案可以扩展到更多的并发请求。
如果我们以标准的同步方式这样做,我们可能会阻塞所有线程等待远程系统返回装运状态。如果发生这种情况,我们的 API 将变得没有响应。我们需要避免这种情况。
通过使用非阻塞的异步代码,我们的 API 不会变得无响应,即使我们与之交互的第三方系统离线。
为了模拟这个假设的 API,我们将使用 Pastebin。进入 https://pastebin.com ,点击新建粘贴按钮,在文本框中输入在途,点击新建粘贴。一旦你这样做了(见图 8-2 ),点击“raw”标签获得其原始网址。复制网址。我们将用它来模拟我们的 API。
图 8-2
单击“raw”复制 API 模拟的原始 Pastebin URL
既然我们已经有了公认的简单的测试 API,我们可以为它编写一个客户端。这个 API 将需要与一个异步网络库一起使用。出于与我们在前面章节中使用aioredis相同的原因,我们将使用一个名为aiohttp的 HTTP 客户端库,需要安装它:
$ pipenv install aiohttp
这样做之后,我们就可以编写我们的消费者了。我们会将其添加到main/consumers.py(用您的 Pastebin URL 替换 put_url_here):
import aiohttp
...
...
class OrderTrackerConsumer(AsyncHttpConsumer):
def verify_user(self, user, order_id):
order = get_object_or_404(models.Order, pk=order_id)
return order.user == user
async def query_remote_server(self, order_id):
async with aiohttp.ClientSession() as session:
async with session.get(
"http://pastebin.com/put_url_here"
) as resp:
return await resp.read()
async def handle(self, body):
self.order_id = self.scope["url_route"]["kwargs"][
"order_id"
]
is_authorized = await database_sync_to_async(
self.verify_user
)(self.scope["user"], self.order_id)
if is_authorized:
logger.info(
"Order tracking request for user %s and order %s",
self.scope.get("user"),
self.order_id
)
payload = await self.query_remote_server(self.order_id)
logger.info(
"Order tracking response %s for user %s and order %s",
payload,
self.scope.get("user"),
self.order_id
)
await self.send_response(200, payload)
else:
raise StopConsumer("unauthorized")
这个消费者是完全异步的,除了数据库查询。它使用 URL 中指定的订单 ID 接受请求,然后将query_remote_server()的结果转发回客户端。
query_remote_server()正在使用我们刚刚安装的库向我们刚刚创建的远程 Pastebin 发出 GET 请求。这样做的结果将简单地传递回客户端。
该消费者将需要一个 URL,该 URL 需要添加到main/routing.py:
...
http_urlpatterns = [
...
path(
"mobile-api/my-orders/<int:order_id>/tracker/",
AuthMiddlewareStack(consumers.OrderTrackerConsumer),
)
]
您现在应该能够测试这一点了。请确保您在系统中至少有一个来自当前用户的订单。如果您使用正确的订单 ID 导航到前面的 URL,您应该会在浏览器中看到您的 Pastebin 的内容。
对此的测试将在main/tests/test_consumers.py中进行:
from unittest.mock import patch, MagicMock
...
class TestConsumers(TestCase):
...
def test_order_tracker_works(self):
def init_db():
user = factories.UserFactory(
email="mobiletracker@site.com"
)
order = factories.OrderFactory(user=user)
return user, order
async def test_body():
user, order = await database_sync_to_async(
init_db
)()
awaitable_requestor = asyncio.coroutine(
MagicMock(return_value=b"SHIPPED")
)
with patch.object(
consumers.OrderTrackerConsumer, "query_remote_server"
) as mock_requestor:
mock_requestor.side_effect = awaitable_requestor
communicator = HttpCommunicator(
consumers.OrderTrackerConsumer,
"GET",
"/mobile-api/my-orders/%d/tracker/" % order.id,
)
communicator.scope["user"] = user
communicator.scope["url_route"] = {
"kwargs": {"order_id": order.id}
}
response = await communicator.get_response()
data = response["body"].decode("utf8")
mock_requestor.assert_called_once()
self.assertEquals(
data,
"SHIPPED"
)
loop = asyncio.get_event_loop()
loop.run_until_complete(test_body())
在前面的测试中,我们用一个返回字符串“SHIPPED”的异步函数修补了整个方法query_remote_server()。通过这种方式,我们从测试中排除了 HTTP 客户端,这使得它运行起来更快,鉴于它的简单性,这对我们来说是一个很好的妥协。
将这一切结合在一起
为了使我们的移动集成尽可能顺利,还有一些更改需要申请。首先,我们的基于令牌的认证,目前只在 DRF 视图上工作,需要包括 WebSocket 和异步 HTTP 路由。
我们需要一个自定义的AuthMiddlewareStack,我们将把它放在一个新文件booktime/auth.py(与settings.py相同的文件夹)中,它在所有其他认证方式的基础上增加了令牌认证:
from urllib.parse import parse_qs
from channels.auth import AuthMiddlewareStack
from rest_framework.authtoken.models import Token
class TokenGetAuthMiddleware:
def __init__ (self, inner):
self.inner = inner
def __call__ (self, scope):
params = parse_qs(scope["query_string"])
if b"token" in params:
try:
token_key = params[b"token"][0].decode()
token = Token.objects.get(key=token_key)
scope["user"] = token.user
except Token.DoesNotExist:
pass
return self.inner(scope)
TokenGetAuthMiddlewareStack = lambda inner: TokenGetAuthMiddleware(
AuthMiddlewareStack(inner)
)
我们将在路由文件中使用这个新的中间件。以下是对booktime/routing.py的更改:
from .auth import TokenGetAuthMiddlewareStack
...
application = ProtocolTypeRouter(
{
"websocket": TokenGetAuthMiddlewareStack(
URLRouter(main.routing.websocket_urlpatterns)
),
...
}
)
以下是对main/routing.py的更改:
from booktime.auth import TokenGetAuthMiddlewareStack
...
http_urlpatterns = [
...
path(
"mobile-api/my-orders/<int:order_id>/tracker/",
TokenGetAuthMiddlewareStack(consumers.OrderTrackerConsumer),
)
]
在开发移动应用或任何移动应用的过程中,我们需要做的最后一件事是确保我们能够满足来自网络的请求,而不仅仅是我们的本地浏览器。
为了改变这一点,我们将为我们的booktime/settings.py添加一个额外的设置:
ALLOWED_HOSTS = ['*']
这导致 Django 允许带有任何“Host”头的请求。请确保该设置在生产环境中是而不是(就像调试一样)。
从现在开始,我们将启动 dev 服务器,并选择监听所有可用的网络接口,而不仅仅是本地接口。我们可以使用以下命令来实现这一点:
$ ./manage.py runserver 0.0.0.0:8000
Performing system checks...
System check identified no issues (0 silenced).
August 22, 2018 - 15:28:01
Django version 2.1, using settings 'booktime.settings'
Starting ASGI/Channels version 2.1.2 development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
2018-08-22 15:28:01,644 - INFO - server - HTTP/2 support not enabled (install the http2 and tls Twisted extras)
2018-08-22 15:28:01,644 - INFO - server - Configuring endpoint tcp:port=8000:interface=0.0.0.0
2018-08-22 15:28:01,645 - INFO - server - Listening on TCP address 0.0.0.0:8000
这将允许我们的应用(运行在移动设备上)通过本地网络连接到我们的服务器。
摘要
本章的目标是使用 Channels(Django 的异步编程扩展)来构建一个聊天后端。信道引入了许多新概念,消费者、路由器、信道层和组,所有这些在本章中都有解释。
实际上,我们为公司的客服代表构建了一个聊天后端及其仪表板。我们还必须直接联系 Redis,以获得我们需要的、Channels 不提供的一些更高级的功能。
在上一节中,我们还讨论了移动应用的必要基础设施,我将在下一章介绍。
Footnotes 1https://asyncio.readthedocs.io/en/latest/
2
https://www.aeracode.org/2018/02/19/python-async-simplified/
3
https://en.wikipedia.org/wiki/Server-sent_events
九、移动客服应用
在本章中,我们将使用 React Native 构建一个基本的移动应用。客户将使用我们开发的应用来检索他们的订单,并与客服代表讨论这些订单。
订单可视化和聊天都将使用我们在第八章中构建的 API。我们将使用这个移动应用来探索如何支持与静态 HTML 页面有不同需求的 API 消费者。
为什么反应本土
在本书中,除了 Django 之外,我不想支持任何技术。我不希望展示一个完美的原生应用,因为这需要更多的设置工作,并且会转移与 Django 集成的注意力。这一章将是关于有足够的应用来建立。
本章将介绍的许多概念可以应用于其他技术堆栈。有许多框架可以构建混合应用。鉴于在前面的章节中已经向您介绍了 React,您将不难理解 React Native。
React Native 重用了 React 的许多概念,但是它使用原生组件而不是 HTML 标签。React Native 在 mobile 上运行,这不是我们用来开发和运行 Django 应用的设备。记住这一点很重要,因为它会影响开发工作流。
我们将像使用 Bootstrap 一样使用 React Native。我们不会深入研究它,但足以消耗我们在第八章中已经完成的工作,并展示我们的用例。
设置本地反应
React Native 是用 JavaScript 写的。要运行下面的命令,您需要至少 8.x)版本的 Node,它提供了npx命令。我们将使用create-react-native-app进行初始设置:
$ npx create-react-native-app booktime_mobile
...
Inside that directory, you can run several commands:
yarn start
Starts the development server so you can open your app in the Expo app on your phone.
yarn run ios
(Mac only, requires Xcode)
Starts the development server and loads your app in an iOS simulator.
yarn run android
(Requires Android build tools)
Starts the development server and loads your app on a connected Android device or emulator.
yarn test
Starts the test runner.
yarn run eject
Removes this tool and copies build dependencies, configuration files and scripts into the app directory. If you do this, you can't go back!
We suggest that you begin by typing:
cd booktime_mobile
yarn start
Happy hacking!
$ cd booktime_mobile
在刚刚创建的booktime_mobile文件夹中,所有 React 原生命令都可以用yarn启动。在启动建议的命令yarn start之前,请注意您可能需要调整一些系统参数。如果是这种情况,您会在屏幕上看到一条警告。
组件层次结构
在开始编写任何复杂的前端代码之前,你应该知道如何将应用的不同屏幕划分成组件。组件是用户界面中可重用的部分。它们可能是整个屏幕,也可能只是屏幕的一部分。
React Native 中的每个组件就像 React 组件一样:它可以有状态和属性。属性是只读的,从父对象传递,而状态是内部管理的。
在我们的例子中,我们简单的应用将有
-
带有用户名/密码表单的登录屏幕。这将是一个独立的组件。
-
订单选择和聊天屏幕。该屏幕将分为两个主要部分。
为了帮助我们识别每个组件在显示屏上的位置,我们可以使用一些视觉提示,如设置边框颜色,如图 9-1 和 9-2 所示。一般来说,为移动设备开发不像在浏览器中开发那样立竿见影。你需要一些时间来适应它。
图 9-2
移动主页
图 9-1
移动登录页面
API 客户端库
在构建任何特定于 React Native 的东西之前,我们将创建一个 JavaScript 类来处理客户机和服务器之间的所有通信。我们这样做是因为,通过模仿它,将更容易测试我们的组件。
除了最初的App.js之外,我们将所有代码放在一个src文件夹中。这将在src/backend.js中(用相关的 URL 代替字符串'CHANGETHIS'):
export default class BackendApi {
constructor (envName) {
if (envName == 'production') {
this.hostName = 'CHANGETHIS'
} else {
this.hostName = 'CHANGETHIS'
}
this.baseHttpApi = 'http://' + this.hostName + '/mobile-api'
}
auth (username, password) {
return fetch(this.baseHttpApi + `/auth/`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: username,
password: password
})
})
.then(response => response.json())
.catch(error => {
console.error(error)
})
.then(response => {
if (response.token) {
this.loggedInToken = response.token
return true
}
return false
})
}
fetchOrders () {
return fetch(this.baseHttpApi + `/my-orders/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: 'Token ' + this.loggedInToken
}
})
.then(response => response.json())
.catch(error => {
console.error(error)
})
}
fetchShipmentStatus (id) {
let url = this.baseHttpApi + `/my-orders/${id}/tracker` +
`/?token=` + this.loggedInToken
return fetch(url, {
method: 'GET',
headers: {
Authorization: 'Token ' + this.loggedInToken
}
})
.then(response=> response.text())
.catch(error=> {
console.error(error)
})
}
openMessagesStream (id, onMessageCb) {
var ws_url =
`ws://` +
this.hostName +
`/ws/customer-service/` +
Id +
`/?token=` +
this.loggedInToken
this.ws = new WebSocket(ws_url)
this.ws.onmessage = function (event) {
var data = JSON.parse(event.data)
onMessageCb(data)
}
this.ws.onerror = function (error) {
console.error('WebSocket error: ', error)
}
this.ws.onopen = function () {
this.heartbeatTimer = setInterval(this.sendHeartbeat.bind(this), 10000)
}.bind(this)
}
closeMessagesStream () {
clearInterval(this.heartbeatTimer)
if (this.ws) {
this.ws.close()
}
}
sendMessage (message) {
this.ws.send(
JSON.stringify({
type: 'message',
message: message
})
)
}
sendHeartbeat () {
this.ws.send(
JSON.stringify({
type: 'heartbeat'
})
)
}
createAbsUrl (relative_uri) {
return 'http://' + this.hostName + relative_uri
}
}
这个基本的 JavaScript 类将处理所有的 HTTP 请求。所有请求都将被定向到实例化该类时指定的环境。
前面代码中的auth()方法使用我们在前一章中创建的认证端点,如果成功,将返回的令牌存储在BackendApi实例中。该令牌将在以后的每个请求中使用。
聊天组件有几种方法:openMessagesStream()、closeMessagesStream()和sendMessage()。在内部使用sendHeartbeat()方法向服务器发出这个客户机存在的信号。
登录查看
我们将把所有的 React 组件放在src/components/中。这是我们的第一个组件,LoginView.js:
import React, { Component } from 'react'
import {
StyleSheet,
Text,
View,
TouchableHighlight,
TextInput
} from 'react-native'
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
welcome: {
fontSize: 20,
textAlign: 'center',
margin:10
},
instructions: {
textAlign: 'center',
color: '#333333',
marginBottom: 5
},
input: {
height: 40,
fontSize: 18,
width: 150
},
button: {
padding: 10
}
})
export default class LoginView extends Component {
constructor (props) {
super(props)
this.state = {
username: '',
password: ''
}
this.handleSubmitLogin = this.handleSubmitLogin.bind(this)
}
handleSubmitLogin () {
if (this.state.username && this.state.password) {
return this .props.backendApi
.auth(this.state.username, this.state.password)
.then(loggedIn => {
if (loggedIn) {
this.props.setLoggedIn()
} else {
this.setState({
username: '',
password: ''
})
alert('Login unsuccessful')
}
})
}
}
render () {
return (
<View style={styles.container}>
<Text style={styles.welcome}>BookTime</Text>
<Text style={styles.instructions}>Please login to see your orders</Text>
<TextInput
style={styles.input}
placeholder="Username"
value={this.state.username}
onChangeText={text => {
this.setState({ username: text })
}}
/>
<TextInput
style={styles.input}
placeholder="Password"
value={this.state.password}
secureTextEntry={true}
onChangeText={text => {
this.setState({ password: text })
}}
/>
<TouchableHighlight
style={styles.button}
onPress={this.handleSubmitLogin}
>
<Text>Submit</Text>
</TouchableHighlight>
</View>
)
}
}
React Native 正在使用 flexbox 作为布局引擎,这是一个来自 CSS 的功能。这用于前面代码中的styles变量。我们的风格大多是做元素的定位和大小,而不是视觉造型。这是我们开始行动的最低要求。
在render()方法中,我们构建渲染树,这是我们使用 React 本地内置组件的地方。View是屏幕一部分的包装器,并不带来额外的功能——它纯粹是其他组件的包装器。
Text是一个标签组件,用来打印文本。TextInput,相当于 HTML 中的<input type="text">,用于输入文本。TouchableHighlight是一个带触摸反馈的按钮。
我们将包含对这个视图的测试。它非常简单,通过直接调用内部函数来避免测试事件触发。只要我们相信我们写的 JSX 1 ,对于我们的目的来说,这是一个可以接受的妥协。
我们将在src/components/LoginView.test.js中放置以下代码:
import React from 'react'
import LoginView from './LoginView'
import renderer from 'react-test-renderer'
it('renders without crashing', () => {
const rendered = renderer.create(<LoginView />)
expect(rendered).toBeTruthy()
})
it('logins successfully when backend returns true', () => {
const setLoggedIn = jest.fn()
const backendApi = {
auth: jest.fn(() => new Promise((resolve, reject) => resolve(true)))
}
const rendered = renderer.create(
<LoginView backendApi={backendApi} setLoggedIn={setLoggedIn} />
)
rendered.root.instance.state.username = 'a'
rendered.root.instance.state.password = 'b'
rendered.root.instance
.handleSubmitLogin()
.then(() => expect(setLoggedIn.mock.calls.length).toBe(1))
expect(backendApi.auth.mock.calls.length).toBe(1)
expect(backendApi.auth.mock.calls[0][0]).toBe('a')
expect(backendApi.auth.mock.calls[0][1]).toBe('b')
})
it('login fails when backend returns false', () => {
const setLoggedIn = jest.fn()
const backendApi = {
auth: jest.fn(() => new Promise((resolve, reject) => resolve(false)))
}
const rendered = renderer.create(
<LoginView backendApi={backendApi} setLoggedIn={setLoggedIn} />
)
rendered.root.instance.state.username = 'a'
rendered.root.instance.state.password = 'b'
rendered.root.instance
.handleSubmitLogin()
.then(() => expect(setLoggedIn.mock.calls.length).toBe(0))
expect(backendApi.auth.mock.calls.length).toBe(1)
expect(backendApi.auth.mock.calls[0][0]).toBe('a')
expect(backendApi.auth.mock.calls[0][1]).toBe('b')
})
it('login fails when backend fails', () => {
const setLoggedIn = jest.fn()
const backendApi = {
auth: jest.fn(() => new Promise((resolve, reject) => reject()))
}
const rendered = renderer.create(
<LoginView backendApi={backendApi} setLoggedIn={setLoggedIn} />
)
rendered.root.instance.state.username = 'a'
rendered.root.instance.state.password = 'b'
rendered.root.instance
.handleSubmitLogin()
.then(() => expect(setLoggedIn.mock.calls.length).toBe(0))
expect(backendApi.auth.mock.calls.length).toBe(1)
expect(backendApi.auth.mock.calls[0][0]).toBe('a')
expect(backendApi.auth.mock.calls[0][1]).toBe('b')
})
我将在本章的后面解释如何运行这个测试。
阅读器
我们将把这个组件专用于所有聊天交互,它将位于主屏幕(src/components/ChatView.js)的一个部分:
import React from 'react'
import {StyleSheet, Text, TextInput, ScrollView, View } from 'react-native'
const styles = StyleSheet.create({
chatContainer: {
borderColor: 'orange',
borderWidth: 1,
flex: 1
},
chatMessages: {
borderColor: 'purple',
borderWidth: 1,
flex: 1
},
chatInput: {
height: 40,
margin: 10,
borderWidth: 1
},
chatMessage: {
backgroundColor: 'lightgrey',
margin: 10,
flex: 1,
flexDirection: 'row',
alignSelf: 'stretch'
},
chatMessageText: {
flex: 1
},
chatMessageType: {
flex: 1,
textAlign: 'right'
}
})
export default class ChatView extends React.Component {
constructor (props) {
super(props)
this.state = {
messages: [],
shipmentStatus: "n/a"
}
this.handleSubmit = this.handleSubmit.bind(this)
}
componentDidUpdate (prevProps) {
if (prevProps.orderCurrentId !== this.props.orderCurrentId) {
this.props.backendApi.closeMessagesStream()
this.state = {
messages: []
}
this.props.backendApi
.fetchShipmentStatus(this.props.orderCurrentId)
.then(result => {
this.setState({shipmentStatus: result})
})
this.props.backendApi.openMessagesStream(
this.props.orderCurrentId,
message => {
this.setState({
messages: this.state.messages.concat([message])
})
}
)
}
}
handleSubmit(event) {
var text = event.nativeEvent.text
this.props.backendApi.sendMessage(text)
this.refs.textInput.setNativeProps({text: '' })
}
render() {
if (this.props.orderCurrentId) {
return (
<View style={styles.chatContainer}>
<Text>Chat for order: {this.props.orderCurrentId}
- { this.state.shipmentStatus}</Text>
<ScrollView
style={styles.chatMessages}
showsVerticalScrollIndicator={true}
ref={ref => (this.scrollView = ref)}
onContentSizeChange={(contentWidth, contentHeight) => {
this.scrollView.scrollToEnd({ animated: true })
}}
>
{this.state.messages.map((m, index) => {
return (
<View key={index} style={styles.chatMessage}>
<Text style={styles.chatMessageText}>
{m.username} {m.message}
</Text>
<Text style={styles.chatMessageType}>{m.type}</Text>
</View>
)
})}
</ScrollView>
<TextInput
style={styles.chatInput}
ref="textInput"
placeholder="Enter a message..."
returnKeyType="send"
onSubmitEditing={this.handleSubmit}
/>
</View>
)
} else {
return (
<View>
<Text>No order selected for chat</Text>
</View>
)
}
}
}
当 React 组件使用传入 2 的新prop进行渲染时,将调用前面代码中的方法componentDidUpdate()。该组件的第一次呈现总是没有选择图书订单,但是当它被选择时,该方法将接收新的订单 id 并打开相应的聊天。
当选择一个顺序时,呈现的组件树将利用ScrollView,它相当于一个固定大小的<div>,内部内容设置为自动在内部溢出。我们还通过使用事件处理程序自动滚动到其内容的底部。
对于ChatView.js中的文本输入,我们使用引用,而不是像在前面的组件中那样存储状态。引用允许 React 代码通过在代码中使用this.refs来直接引用组件。
这是测试(src/components/ChatView.test.js):
import React from 'react'
import ChatView from './ChatView'
import renderer from 'react-test-renderer'
it('renders without orders specified', () => {
const rendered = renderer.create(<ChatView />).toJSON()
expect(rendered).toBeTruthy()
})
it('renders with orders specified', () => {
const backendApi = {
openMessagesStream: jest.fn(),
closeMessagesStream: jest.fn(),
sendMessage: jest.fn(),
fetchShipmentStatus: jest.fn(
() => new Promise((resolve, reject) => resolve())
),
}
const rendered = renderer.create(
<ChatView backendApi={backendApi} orderCurrentId="1" />
)
rendered.root.instance.componentDidUpdate({})
expect(backendApi.openMessagesStream.mock.calls.length).toBe(1)
expect(backendApi.openMessagesStream.mock.calls[0][0]).toBe('1')
expect(backendApi.fetchShipmentStatus.mock.calls.length).toBe(1)
expect(backendApi.fetchShipmentStatus.mock.calls[0][0]).toBe('1')
rendered.getInstance().setState({shipmentStatus: 'shipped'})
backendApi.openMessagesStream.mock.calls[0]1
expect(rendered.toJSON()).toMatchSnapshot()
rendered.root.instance.handleSubmit({ nativeEvent:{ text: 'answer back' } })
expect(backendApi.sendMessage.mock.calls.length).toBe(1)
expect(backendApi.sendMessage.mock.calls[0][0]).toBe('answer back')
expect(rendered.toJSON()).toMatchSnapshot()
})
订单 w
该组件将管理登录屏幕后的整个屏幕。它还将加载所有子组件,包括 ChatView。
下面是组件(src/components/OrderView.js):
import React from 'react'
import{
StyleSheet,
Text,
Image,
TouchableHighlight,
TouchableOpacity,
View
} from 'react-native'
import ChatView from './ChatView'
const styles = StyleSheet.create({
container: {
paddingTop: 25,
flex: 1
},
orderContainer: {
borderColor: 'blue',
borderWidth: 1,
height: 200
},
orderRowOut: {
borderColor: 'yellow',
borderWidth: 1,
height: 52
},
orderRowIn: {
flex: 1,
flexDirection: 'row',
alignSelf: 'stretch'
},
orderSelected: {
backgroundColor: 'lightgrey'
},
orderImage: {
borderColor: 'green',
borderWidth: 1,
width: 50,
height: 50
},
orderSummary: {
borderColor: 'red',
borderWidth: 1,
flex: 1,
alignSelf: 'stretch'
},
orderPrice: {
borderColor: 'blue',
borderWidth: 1
}
})
function OrderImage (props) {
if (props.image) {
return (
<Image
style={{ width: 50, height: 50 }}
source={{ uri: props.backendApi.createAbsUrl(props.image) }}
/>
)
} else {
return <View />
}
}
function OrderTouchArea (props) {
return (
<View
style={[
styles.orderRowIn,
props.order.id == props.orderCurrentId ? styles.orderSelected: null
]}
>
<View style={styles.orderImage}>
<OrderImage backendApi={props.backendApi} image={props.order.image} />
</View>
<Text style={styles.orderSummary}>{props.order.summary}</Text>
<Text style={styles.orderPrice}>{props.order.price}</Text>
</View>
)
}
function OrderSingleView (props) {
return (
<TouchableHighlight
style={styles.orderRowOut}
onPress={() => props.setOrderId(props.order.id)}
>
<OrderTouchArea
backendApi={props.backendApi}
order={props.order}
orderCurrentId={props.orderCurrentId}
/>
</TouchableHighlight>
)
}
export default class OrderView extends React.Component {
constructor (props) {
super(props)
this.state = {
orders: [],
orderCurrentId: null
}
}
componentDidMount () {
this.props.backendApi
.fetchOrders()
.then(orders => this.setOrders(orders))
.catch(() => alert('Error fetching orders'))
}
setOrders (orders){
this.setState({
orders: orders
})
}
render(){
return (
<View style={styles.container}>
<View style={styles.orderContainer}>
<Text>Your BookTime orders</Text>
{this.state.orders.map(m=> (
<OrderSingleView
backendApi={this.props.backendApi}
key={m.id}
order={m}
orderCurrentId={this.state.orderCurrentId}
setOrderId={ordered => this.setState({ orderCurrentId: orderId })}
/>
))}
</View>
<ChatView
backendApi={this.props.backendApi}
orderCurrentId={this.state.orderCurrentId}
/>
</View>
)
}
}
在 React Native 中,像 React 的最新版本一样,可以将组件定义为函数或类。对于简单的组件,函数就足够了。这是我们在这里使用的,使代码更可读。
声明为函数的组件没有内部状态。前面代码中声明的组件是纯函数,它们的输出完全基于传入的属性。
早期组件中的所有状态都由主OrderView组件管理。
一旦安装了组件,就会获取订单,这意味着它会显示在屏幕上。可以在 UI 中按下订单条目,这将初始化聊天组件并打开到我们服务器的 WebSocket 连接。
下面是测试(src/components/OrderView.test.js):
import React from 'react'
import OrderView from './OrderView'
import renderer from 'react-test-renderer'
it('renders without crashing', () => {
const mockOrders = [{ id: 5, image: null, summary: '2xaa', price: 22 }]
const backendApi = {
fetchOrders: jest.fn(
() => new Promise((resolve,reject)=>resolve(mockOrders))
),
createAbsUrl: jest.fn(url => 'http://booktime.domain' + url)
}
const rendered = renderer.create(<OrderView backendApi={backendApi} />)
expect(rendered).toBeTruthy()
expect(backendApi.fetchOrders.mock.calls.length).toBe(1)
rendered.getInstance().setOrders(mockOrders)
expect(rendered.getInstance().state.orders).toBe(mockOrders)
expect(rendered.toJSON()).toMatchSnapshot()
})
主要成分
我们将用启动应用所需的代码覆盖主文件夹中自动生成的App.js的内容:
import React from 'react'
import LoginView from './src/components/LoginView'
import ChatView from './src/components/ChatView'
import OrderView from './src/components/OrderView'
import BackendApi from './src/backend'
export default class App extends React.Component {
constructor (props) {
super(props)
this.backendApi = new BackendApi()
this.state = {
loggedIn: false
}
}
render () {
if (this.state.loggedIn) {
return <OrderView backendApi={this.backendApi} />
} else {
return (
<LoginView
backendApi={this.backendApi}
setLoggedIn={() => this.setState({loggedIn: true })}
/>
)
}
}
}
我们在这个类中构造 API 对象,并可选地传递我们希望这个应用运行的环境,无论是生产环境还是不太重要的环境。
主容器只有一个状态标志loggedIn,用于显示第一个屏幕(登录)或主屏幕(订单视图)。
我们将把它的测试放在同一个文件夹和文件App.test.js中:
import React from 'react'
import App from './App'
import renderer from 'react-test-renderer'
it('renders without crashing', () => {
const rendered = renderer.create(<App />).toJSON()
expect(rendered).toBeTruthy()
})
运行、测试和打包
正如本章前面提到的,一切都是用yarn运行的。我们使用yarn start来启动我们的移动开发服务器(见图 9-3 )。要查看该应用,我们将在移动设备上安装一个名为 Expo ( https://expo.io )的开发客户端。请从您移动设备的应用商店下载此移动应用。
我们也可以用 Jest 运行我们在本章前面写的测试。我们正在使用 Jest 的一个称为快照测试的特性,它为我们简化了很多断言工作。
下面是yarn test的输出,它在幕后使用 Jest:
$ yarn test
yarn run v1.7.0
$ jest
PASS src/components/OrderView.test.js (6.403s)
› 1 snapshot written.
PASS src/components/LoginView.test.js (6.576s)
PASS ./App.test.js
PASS src/components/ChatView.test.js (8.404s)
- Console
console.warn node_modules/react-native/jest/setup.js:93
Calling .setNativeProps() in the test renderer environment is not supported. Instead, mock
- 2 snapshots written.
Snapshot Summary
- 3 snapshots written in 2 test suites.
Test Suites: 4 passed, 4 total
Tests: 8 passed, 8 total
Snapshots: 3 added, 3 total
Time: 9.967s
Ran all test suites.
Done in 11.87s.
在第一次运行yarn test时,它会为我们生成所有的快照。在所有后续运行中,它将使用生成的快照作为参考进行检查。
图 9-3
纱线起始样本输出
Android 包装
到目前为止,我们所做的所有工作都让我们知道,我们有一个潜在的应用,但我们仍然需要将其打包并分发给我们的客户,这就是以下步骤成为特定于平台的地方。
我将展示如何在 Android 上做到这一点,因为 apk(打包的应用)的分发不需要您登录 Google Play。您可以在使用npm install -g exp安装了exp命令行界面之后这样做
世博会客户(exp)将要求您在他们的系统上建立一个账户。打包 React 本地应用不需要这个客户端,但是如果没有它,我们将不得不做更多的设置工作。我们将不得不安装我们想要定位的所有平台的 SDK。使用 Expo 客户端允许我们使用他们的系统来编译我们的文件。
$ exp build:android
[16:29:11] Making sure project is set up correctly...[16:29:13] Your project looks good!
[16:29:13] Checking if current build exists...
[16:29:14] No currently active or previous builds for this project.
[16:29:15] Unable to find an existing exp instance for this directory, starting a new one...
[16:29:16] Starting Metro Bundler on port 19001.
[16:29:17] Metro Bundler ready.
[16:29:17] Publishing to channel 'default'...
[16:29:17] Tunnel URL not found, falled back to LAN URL.
[16:29:17] Building iOS bundle
[16:29:37] Tunnel URL not found, falled back to LAN URL.
[16:29:47] Building Android bundle
Building JavaScript bundle [======================] 100%[16:29:47]
[16:29:57] Tunnel URL not found, falled back to LAN URL.
[16:30:14] Analyzing assets
Building JavaScript bundle [======================] 100%[16:30:14]
Finished building JavaScript bundle in 26994ms.
[16:30:17] Tunnel URL not found, falled back to LAN URL.
Building JavaScript bundle [======================] 100%[16:30:23]
Finished building JavaScript bundle in 9449ms.
[16:30:33] Uploading assets
Building JavaScript bundle [=======================] 100%[16:30:33]
Finished building JavaScript bundle in 9853ms.
[16:30:34] No assets changed, skipped.
[16:30:34] Uploading JavaScript bundles
[16:30:37] Tunnel URL not found, falled back to LAN URL.
[16:30:39] Published
[16:30:39] Your URL is
https://exp.host/@flagz/booktime_mobile
[16:30:39] Building...
[16:30:40] Build started, it may take a few minutes to complete.
[16:30:40] You can check the queue length at
https://expo.io/turtle-status
[16:30:40] You can monitor the build at
https://expo.io/builds/0bcd5ecb-bd99-419d-94fa-44433820be5e
|[16:30:40] Waiting for build to complete. You can press Ctrl+C to exit.
[16:34:42] Successfully built standalone app: https://expo.io/artifacts/793ffef3-10a2-4803-a791-c659756c3a8b
成功运行之后,您可以通过输出中的链接下载生成的 APK。
摘要
本章介绍了一个简单的 React Native 应用,它使用了我们在第八章中编写的 API。您可以学习这里介绍的课程并扩展它们,或者使用 Java 或 Swift 简单地重写这个应用。
Footnotes 1https://reactjs.org/docs/introducing-jsx.html
2
https://reactjs.org/docs/components-and-props.html