{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Kivy指南-4-Kivy网络编程\n", "\n", "> 实现聊天app的客户端-服务器架构,利用Twisted框架实现服务器\n", "\n", "- toc: true \n", "- badges: true\n", "- comments: true\n", "- categories: [jupyter,Kivy,Android,iOS]\n", "- image: kbpic/4.7chatlast.png" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "前面我们尝试过单一平台Android的Kivy开发,通过原生的底层API来实现。下面我们探索另外一种天生具有跨平台能力的工具来做app——网络。在这一章,我们做一个聊天app,类似于QQ,但是简单版。\n", "\n", "当然这个应用不能替代QQ,不过支持类似QQ群聊天功能,方便临时性的会议建群聊天。\n", "\n", "为了简化,我们不实现认证授权功能,这是为彼此都很熟悉的用户设计的。如果你想让app更安全,自己可以实现一下。\n", "\n", "为了在服务器端支持最大兼容性,这个app用**Telnet**收发消息。虽然不是Kivy的app的图形用户界面,Telnet可以在Windows 95和MS-DOS完美运行。\n", ">更严谨的考证一下,其实Telnet在1973年就标准化了,因此它甚至可以在8086 CPU上运行。Windows 95和MS-DOS相比之下已经很新了。\n", "\n", "本章教学大纲如下:\n", "\n", "- 用Python的Twisted框架实现服务器端。\n", "- 在不同的抽象层面上开发两个客户端,一个是通过套接字实现命令行程序,一个是通过Twisted的事件驱动客户端实现的程序\n", "- 用Kivy的`ScreenManager`更容易的实现UI\n", "- 做一个`ScrollView`容器实现消息的无限长度\n", "\n", "我们的应用将使用中心化的客户端-服务器架构,很多网站和应用都用这种主流的互联网方法论。与去中心化的P2P网络相比,你很快会发现这种方法是多么的容易。\n", ">这里没有区分互联网与局域网(local area network,LAN),但是两者在底层上没啥关系。但是,如果你要把应用发布到应用商店,你还需要准备很多其他的内容,比如设置一个安全网络服务器,配置防火墙来保证你的代码可以扩展到多核处理器和其他设备。实际上这并没有多可怕,但是仍然需要一些努力。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 实现聊天服务器" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "开始写客户端之前我们先把服务器端做出来。我们用**Twisted**框架来提高效率,通过Python来实现,这样可以避开许多常见的、底层的网络任务。\n", ">**兼容性提示**\n", ">Twisted目前仍然不能很好的不支持Python3,所以这里得使用Python2.7。其实2to3很容易移植,因为没多少不兼容的设计方式。(不过,我们完全忽视了Unicode相关的问题,因为Python2和Python3的处理方式不同。如果是中文,还是Python3方便。)\n", "\n", "Twisted是一个事件驱动的底层的服务器框架,不像**Node.js**(实际上,Node.js的设计受到Twisted的影响)。与Kivy很类似,事件驱动的架构意味着我们不需要把代码构建一个无限循环,相反我们只要为app绑定大量的事件监听器就行。许多底层的网络处理,都可以通过Twisted方便的实现。\n", ">和其他Python包一样,用pip就可以安装Twisted: \n", ">**pip install -U twisted**" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 协议定义" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "现在我们来看下聊天服务器即将使用的通信协议,因为我们的app并不复杂,所以我们不用XMPP那样主流的、功能丰富的程序,我们自己实现一个简单的就行。\n", "\n", "我们实现的协议层就是两条信息在客户端和服务器传递——连接服务器(进入聊天室),对其他人说话。服务器会反馈客户端传递的每件事情;服务器自己不会产生任何事件。\n", "\n", "我们的协议执行原文传递,和很多其他的应用层如HTTP协议一样。这样做很合理,因为调试和实现起来都很方便。字符串协议比二进制码协议更具扩展性和前瞻性(future-proof)。缺点就是包压缩率底,占用资源多;二进制协议可以更紧凑。不过在这个app讨论这样不太重要,也可以通过压缩技术缓解这个不足(这就是为啥很多服务器都是HTTP协议)。\n", "\n", "现在,让我们梳理一下每条消息在应用协议的思路:\n", "\n", "- 连接到服务器的信息只有用户现在是否在聊天室,每次就用一个单词`CONNECT`来检测。这个信息不需要参数化,直接用单词。\n", "- 在聊天室说话更有趣。有两个参数:用户名和文字信息。让我们把格式定义为`A:B`,`A`就是用户名(我们要求用户名不能包含分号`:`字符)。\n", "\n", "根据这个思路,我们写出下面的算法(伪代码):\n", "\n", "```\n", "if ':' not in message\n", " then\n", " // it's a CONNECT message\n", " add this connection to user list\n", " else\n", " // it's a chat message\n", " nickname, text := message.split on ':'\n", " for each user in user list\n", " if not the same user:\n", " send \"{nickname} said: {text}\"\n", "``` " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "对同一个用户的测试就是把向用户自己传递的信息去掉。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 服务器代码" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "有了Twisted的帮助,我们的伪代码可以很直接的写出Python代码。下面就是我们应用的`server.py`文件:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "from twisted.internet import protocol, reactor\n", "\n", "transports = set()\n", "\n", "\n", "class Chat(protocol.Protocol):\n", " def dataReceived(self, data):\n", " transports.add(self.transport)\n", "\n", " if \":\" not in data:\n", " return\n", " user, msg = data.split(\":\", 1)\n", "\n", " for t in transports:\n", " if t is not self.transport:\n", " t.write(\"{0} says: {1}\".format(user, msg))\n", "\n", "\n", "class ChatFactory(protocol.Factory):\n", " def buildProtocol(self, addr):\n", " return Chat()\n", "\n", "\n", "reactor.listenTCP(9096, ChatFactory())\n", "reactor.run()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 设计原理" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "下面的操作流程可以帮助你理解上面的代码:\n", "\n", "- 最后一行`reactor.run()`开启`ChatFactory`服务器监听9096端口\n", "- 当服务器收到请求,就调用`dataReceived()`响应\n", "- `dataReceived()`方法就是前面伪代码的实现,会把消息发送给其他用户\n", "\n", "每个到客户端的连接构成集合`transports`。我们无条件的把当前的传递`self.transport`加入集合。\n", "\n", "然后就是算法的实现。最后,聊天室内除了发消息的每个用户都会收到提示,< **username**>说了<**message text**>。\n", ">注意我们并没有用`CONNECT`来检查连接的信息。这是按照1980年Jon Postel在TCP说明书里面提出的*网络稳健性(network robustness)*原则设计的:保守的发送,自由的接收。\n", ">另外通过简化代码,我们还获得了更好的兼容性。在未来要发布新版本客户端时,如果我们给协议增加一个新消息,假设叫`WHARRGARBL`消息,名字看着就很酷。没有崩溃是因为虽然收到来一个格式错误的消息(这是由于版本不匹配),老版本的服务器会直接忽略这些消息继续工作。\n", ">这些具体的版本兼容性问题可以通过许多策略来纠正。但是,还有更多来自网络尤其是公网的难题,包括用户恶意攻击拖垮服务器等等。所以,实际中并没有服务器非常稳定这种可能。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 服务器测试" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "直接运行Python文件就可以测试服务器:\n", "```sh\n", "python server.py\n", "```\n", "这个命令的结果不会直接看到。服务器开始运行,等待客户请求。但是,我们还没客户端程序。那怎么测试呢?\n", "\n", "这种先有服务器还是先有客户端的经典问题有很多方法可以解决——向服务器发信息,然后显示服务器反馈的信息。\n", "\n", "处理字符串协议服务器的一个标准化工具就是Telnet,一般都是命令行,没有图像界面。很多操作系统都带有Telnet。在Windows系统中,打开“控制面板|程序和功能|启动或关闭Windows功能”就可以找到Telnet。\n", "\n", "![Telnet](kbpic/4.1Telnet.png)\n", "\n", "Telnet有两个参数:服务器地址和端口。为了连接到Telnet,你需要先启动`server.py`,然后再打开命令行输入:\n", "```sh\n", "telnet 127.0.0.1 9096\n", "```\n", "\n", "另外,你也可以用`localhost`代替`127.0.0.1`,在`hosts`文件中是默认设置。\n", "\n", "现在就可以测试服务器了。你可以根据前面设计流程,向服务器发送内容进行实现测试:\n", "\n", "```\n", "CONNECT\n", "User A:Hello, world!\n", "```\n", "\n", "没有出现任何反馈,因为我们没有让服务器向原作者反馈信息。因此,我们需要打开另外一个命令行,然后以一个新的用户登录。就可以看到`User A`发送的信息了。如下图所示:\n", "\n", "![servertest](kbpic/4.2servertest.png)\n", "\n", ">如果你没法儿正常测试Telnet也甭灰心,这不影响咱们app的顺利完成。\n", ">如果用Windows的话,给一点小建议:最好给电脑装个Mac OS或Linux,双系统更适合研发工作,推荐使用虚拟机,切换方便。\n", "\n", "这样,我们就知道服务器可以正常工作了。现在我们来做客户端系统的GUI。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 屏幕管理器" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "现在我们用一个新概念来设计UI,叫屏幕管理器。用来设计我们的app合适。一共是两个UI状态:\n", "\n", "- 登录界面:包括服务器地址、用户名、登录按钮\n", "![uilogin](kbpic/4.3uilogin.png)\n", "- 聊天界面:包括信息显示、信息输入、发送信息按钮和端口服务器按钮\n", "![uilogin](kbpic/4.4uichat.png)\n", "\n", "从理论上说,我们的app界面就是这样。这种UI分离的设计方法涉及到,对不同UI状态里可见的与隐藏的控件的管理。这样可以快速的组合一堆部件,而不要写任何代码。\n", "\n", "Kivy为我们提供`ScreenManager`来实现UI设计。另外,`ScreenManager`还提供了屏幕切换的动态过程,以及大量的内置转换方式。可以完全通过Kivy语言来实现,不需要任何Python代码。" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "下面让我们来实现,首先建立`chat.kv`文件:\n", "```yaml\n", "ScreenManager:\n", " Screen:\n", " name: 'login'\n", "\n", " BoxLayout:\n", " # other UI controls -- not shown\n", " Button:\n", " text: 'Connect'\n", " on_press: root.current = 'chatroom'\n", " \n", " Screen:\n", " name: 'chatroom'\n", " \n", " BoxLayout:\n", " # other UI controls -- not shown\n", " Button:\n", " text: 'Disconnect'\n", " on_press: root.current = 'login'\n", "```\n", "这是程序的基本结构:我们建立`ScreenManager`根部件,并为每个状态建立一个`Screen`容器。容器里面有上面看到的布局、按钮。后面我们会继续完善。\n", "\n", "代码里面看到还包括`Screen`的按钮。为了切换应用的状态,我们还需要设置`ScreenManager`的`current`属性。" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "### 屏幕切换动作" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "前面提到,屏幕可以通过切换动作的动态显示。Kivy提供了许多切换功能,在`kivy.uix.screenmanager`包里面:" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "| Transition class name | Visual effect |\n", "|::|::|\n", "| `NoTransition` | 没有动画,直接显示屏幕 |\n", "| `SwapTransition` | 滑到下一屏幕,用上、下、左(默认)、右。 |\n", "| `SwapTransition` | iOS屏幕切换效果 |\n", "|`FadeTransition` | 褪色方式切换 |\n", "|`WipeTransition` | 用1px的遮挡实现平滑的定向切换 |\n", "|`FallOutTransition` | 将旧屏幕缩小到屏幕中间,渐渐透明,再出现新屏幕 |\n", "|`RiseInTransition` | 与`FallOutTransition`完全相反,新屏幕从中间出现,放大直到遮住旧屏幕 |" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "在`.lv`文件里面设置这些切换动作时需要注意一旦:切换需要手动导入。\n", "```yaml\n", "#:import RiseInTransition kivy.uix.screenmanager.RiseInTransition\n", "```\n", "现在,你就可以配置`ScreenManager`了。注意这些动作都是Python类的实例,所以后面要加括号:\n", "```yaml\n", "ScreenManager:\n", " transition: RiseInTransition()\n", "```" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "### 登录界面布局" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "登录界面布局和前一章的录音app类似:用一个`GridLayout`把各个元素按照网格排列。\n", "\n", "还没用过的部件就是文本框`TextInput`。Kivy的文本框和按钮基本完全一样,区别就是可以输入文字。默认情况下是多行显示,因为在聊天app里面多行少见(如微信、QQ),所以要设置`multiline`为`False`。\n", "\n", "在无键盘设备上运行时,Kivy会调用虚拟键盘,和原生应用一样。下面的代码就是登录界面布局:\n", "\n", "```yaml\n", "Screen:\n", " name: 'login'\n", " BoxLayout:\n", " orientation: 'vertical'\n", " GridLayout:\n", " Label:\n", " text: 'Server:'\n", " TextInput:\n", " id: server\n", " text: '127.0.0.1'\n", " Label:\n", " text: 'Nickname:'\n", " TextInput:\n", " id: nickname\n", " text: 'Kivy'\n", " Button:\n", " text: 'Connect'\n", " on_press: root.current = 'chatroom'\n", "```\n", "\n", "这里,我们增加了`Server`和`Nickname`两个文本框,对应的标签和按钮。按钮的事件handler还有没有任何功能,后面会实现。\n", "\n", "可以让单行的`TextInput`更好看点,我们让文本框里面的文字垂直居中:\n", "```yaml\n", ":\n", " multiline: False\n", " padding: [10, 0.5 * (self.height – self.line_height)]\n", "```\n", "`padding`属性设置了左右的边距都是10,上面和下面的边距是文本框高度与一行文本高度之差的一半。下面就是效果图,可前面app的界面类似:\n", "\n", "![loginscreen](kbpic/4.5loginscreen.png)\n", "\n", "现在我们可以写代码来连接服务器了,不过之前我们先把聊天主界面做出来。这样我们就可以直接在上面测试了。" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "### 聊天界面布局" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "聊天界面布局使用了`ScrollView`实现对话长度的切换,因为是第一次说这个空间,下面会详细介绍:\n", "```yaml\n", ":\n", " text_size: (self.width, None) # Step 1\n", " halign: 'left'\n", " valign: 'top'\n", " size_hint: (1, None) # Step 2\n", " height: self.texture_size[1] # Step 3\n", "ScrollView:\n", " ChatLabel:\n", " text: 'Insert very long text with line\\nbreaks'\n", "```\n", "\n", "文字满屏之后,滚动条会出现,类似于在Android和iOS里面看到的。具体的设计流程如下:\n", "\n", "1. 我们用`text_size`属性设置`Label`子类的宽度,把第二个参数高度设置成`None`,允许无限长度\n", "2. 把`size_hint`属性第二个参数设置为`None`,允许无限长度,迫使其高度与它的容器`ScrollView`独立。但是,它的长度会受到上一层的元素的限制,因此不会滚动。\n", "3. 设置部件的高度等于`texture_size`属性高度(注意索引都从0开始,因此第二个元素是`texture_size[1]`)。这就迫使`ChatLabel`比包含它的`ScrollView`部件大\n", "4. 当`ScrollView`部件发现它的子部件比它的空间大时,滚动条就出现了。这和手机上看到的一样,桌面系统也支持鼠标滚轮操作。" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "#### 滚动模式" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "你还可以为`ScrollView`设置滚动的效果来模仿原生平台的特点(不过和原生的效果还是有差别)。可以实现的效果如下:\n", "\n", "- `ScrollEffect`:当触及最底部的时候可以停止滚动,类似于桌面应用常见的功能\n", "- `DampedScrollEffect`:这是默认的效果,类似于iOS,非常适合移动设备\n", "- `OpacityScrollEffect`:与`DampedScrollEffect`效果类似,滚动时增加了滚动条的透明度,不会遮挡内容\n", "\n", "要使用这些效果,从`kivy.effects`模块导入效果,然后配置到`ScrollView.effect_cls`属性,与`ScreenManager`切换效果类似。本章app不改,就用默认效果,可以自行设置。\n", "\n", "把上述内容综合起来,`chat.kv`文件代码如下:\n", "\n", "```yaml\n", "Screen:\n", " name: 'chatroom'\n", " BoxLayout:\n", " orientation: 'vertical'\n", " Button:\n", " text: 'Disconnect'\n", " on_press: root.current = 'login'\n", " ScrollView:\n", " ChatLabel:\n", " id: chat_logs\n", " text: 'User says: foo\\nUser says: bar'\n", " BoxLayout:\n", " height: 90\n", " orientation: 'horizontal'\n", " padding: 0\n", " size_hint: (1, None)\n", " TextInput:\n", " id: message\n", " Button:\n", " text: 'Send'\n", " size_hint: (0.3, 1)\n", "```\n", "\n", "最后一行的`size_hint`属性设置了按钮的水平比例为0.3,默认的是1。这就会让发送按钮比文本框小。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "为了把消息的背景色设置成白色的,我们可以这样:\n", "```yaml\n", ":\n", " canvas.before:\n", " Color:\n", " rgb: 1, 1, 1\n", " Rectangle:\n", " pos: self.pos\n", " size: self.size\n", "```\n", "\n", "这就在其他元素绘制之前为`ScrollView`铺上了白色。别忘记了调整一下``类,把背景色设置成浅色背景:\n", "```yaml\n", "#:import C kivy.utils.get_color_from_hex\n", "\n", ":\n", " color: C('#101010')\n", "```\n", "效果图如下:\n", "![Chatroomscreen](kbpic/4.6Chatroomscreen.png)\n", "\n", "这里Disconnect按钮就是断开网络连接。这是下一章的内容,到时候你会发现,用Python实现简单网络程序的难度,与用Kivy建立简单的UI没啥区别。" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "## 连接网络" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "下面我们连接服务器来收发消息,并显示给用户。\n", "\n", "首先,我们看一个纯Python实现的聊天客户端,用套接字就可以实现。不过还是推荐用高级工具,如Twisted;如果你对这类知识没有一点儿概念,可能有点小困难,坚持一下就会好,多试几次准明白。" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "### 一个简单Python客户端" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "下面我们将用`readline()`函数读取用户的消息,然后通过`print()`函数显示在命令行上。这和Telnet没啥区别——都是命令行界面显示消息——只是我们从底层的套接字开始做起。\n", "\n", "这需要一些标准模块:`socket`,`sys`(`sys.stdin`提供输入文件接口)和`select`模块等待消息出现。新建一个客户端文件`client.py`:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "import select, socket, sys" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "这个程序没有其他依赖关系;所有平台的Python都支持。\n", ">不过Windows里面的`select`,由于其代码实现方式不同,不能把文件描述器调整为套接字接受的样式。所以这个客户端就不能运行了,不过这个客户端我们最后也不会用,所以不要担心,如果你用的是Windows。\n", "\n", "现在,我们来连接服务然后用`CONNECT`对接:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", "s.connect((\"127.0.0.1\", 9096))\n", "s.send(\"CONNECT\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "然后就是等待两类消息,一类是标准输入(用户输入的文字),一类`s`套接字(服务器发送消息)。等待可以用`select.select()`实现:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "rlist = (sys.stdin, s)\n", "while True:\n", " read, write, fail = select.select(rlist, (), ())\n", " for sock in read:\n", " if sock == s: # receive message from server\n", " data = s.recv(4096)\n", " print(data)\n", " else: # send message entered by user\n", " msg = sock.readline()\n", " s.send(msg)" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "然后,服务器根据收到的最新数据进行反馈,我们可以把服务器发送的信息显示到屏幕上,或者如果是用户发送的消息就发送到服务器。其实这就是Telnet做的事情,只是缺少错误异常检测部分。\n", "\n", "你会发现底层的模块实现客户端并没有我们想象的困难。但是相比高级模块如Twisted,原始套接字代码还是冗长的。但是这里显示了客户端工作的原理,其他任何高级工具都是这里实现的,高级工具也不过是通过底层的套接字实现,然后加上方便使用的API而已。\n", "\n", ">注意这里我们没有加异常检测部分,因为代码可能增加2-3倍,感兴趣的可以练习一下。\n", ">网络是非常容易出错的;比其他IO都脆弱。因此,如果你打算做一个类似于Skype那样的商业软件,你的代码里面将充斥异常检测和测试:比如丢包,防火墙问题等等。不论你的架构设计的多么好,网络服务想获得极高的可靠性很难。" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "### 用Twisted实现" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "纯Python代码写的客户端不太适合Kivy,原因就是主循环部分(`while True:`)。要让这个循环与Kivy的事件循环协同运作,还得做些事情。\n", "\n", "不过,Twisted的优势可以很好的弥补这一点,实现同样的网络模块可以同时作用于服务器和客户端,使得代码更统一。关键在于Twisted可以与Kivy的事件循环协同运作,首先让我们把Twisted相关模块导入:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "from kivy.support import install_twisted_reactor\n", "\n", "install_twisted_reactor()\n", "\n", "from twisted.internet import reactor, protocol" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "这段代码要放在`main.py`文件的最上面。下面我们用Twisted来实现:" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### ChatClient和ChatClientFactory" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "用Twisted实现工作量很少,因为这个框架为网络相关的每一件事情都做了很好的接口,这些类通过简单的连接就可以完成工作。\n", "\n", "` ClientFactory`的子类`ChatClientFactory`可以在初始化阶段储存Kivy app的实例,这样我们就可以向它传递事件。代码如下:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "class ChatClientFactory(protocol.ClientFactory):\n", " protocol = ChatClient\n", "\n", " def __init__(self, app):\n", " self.app = app" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "`ChatClient`类监听Twisted的`connectionMade`和`dataReceived`事件,然后发送到Kivy app:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "class ChatClient(protocol.Protocol):\n", " def connectionMade(self):\n", " self.transport.write(\"CONNECT\")\n", " self.factory.app.on_connect(self.transport)\n", "\n", " def dataReceived(self, data):\n", " self.factory.app.on_message(data)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "注意那个无所不在的`CONNECT`握手信号。\n", "\n", "这和前面的套接字写法很不同,是吧?而且和前面的`server.py`很像。但是,我们只是把事件传递给aoo对象,并没有处理事件。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 加入UI" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "要看到app的全貌,我们还要把UI也加进来。`chat.kv`文件如下:\n", "```yaml\n", "Button: # Connect button, found on login screen\n", " text: 'Connect'\n", " on_press: app.connect()\n", "Button: # Disconnect button, on chatroom screen\n", " text: 'Disconnect'\n", " on_press: app.disconnect()\n", "TextInput: # Message input, on chatroom screen\n", " id: message\n", " on_text_validate: app.send_msg()\n", "Button: # Message send button, on chatroom screen\n", " text: 'Send'\n", " on_press: app.send_msg()\n", "```\n", "\n", "注意按钮不会再切换屏幕了,相反它们调用`app`的方法,类似于`ChatClient`事件处理。\n", "\n", "完成这些之后,我们需要实现Kivy应用类里面的5个方法:两个是Twisted代码中的服务器生成事件(`on_connect`和`on_message`),三个是用户接口事件(`connect`,`disconnect`和`send_msg`)。这样才能让聊天app真正可以用。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 客户端的设计思路" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "当我们简单写一些程序运行逻辑:从`connect()`到`disconnect()`。\n", "\n", "在`connect()`方法里面,我们把**Server**和**Nickname**参数作为用户输入。**Nickname**参数被储存到`self.nick`,Twisted客户端连接到具体的服务器地址:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "class ChatApp(App):\n", " def connect(self):\n", " host = self.root.ids.server.text\n", " self.nick = self.root.ids.nickname.text\n", " reactor.connectTCP(host, 9096, ChatClientFactory(self))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "调用`ChatClient.connectionMade()`函数,把控件传递到`on_connect()`方法上。我们将用事件来储存`self.conn`连接,然后切换屏幕。前面提到过,按钮不再直接切换屏幕,而且通过事件handler实现:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "# From here on these are methods of the ChatApp class\n", "def on_connect(self, conn):\n", " self.conn = conn\n", " self.root.current = \"chatroom\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "现在主要部分就是收发信息。很简单,就是从`TextInput`发信息,把`self.nick`加上,发送给服务器。最后把信息显示出来,并且清空`TextInput`。" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "def send_msg(self):\n", " msg = self.root.ids.message.text\n", " self.conn.write(\"%s:%s\" % (self.nick, msg))\n", " self.root.ids.chat_logs.text += \"%s says: %s\\n\" % (self.nick, msg)\n", " self.root.ids.message.text = \"\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "接受消息更简单,因为我们不会保持这些内容,所有就是把消息显示到屏幕上:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "def on_message(self, msg):\n", " self.root.ids.chat_logs.text += msg + \"\\n\"" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "最后一个方法就是`disconnect()`:关闭连接,清理所有内容回到初始界面。这样用户就可以重新连接其他服务器了。" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "def disconnect(self):\n", " if self.conn:\n", " self.conn.loseConnection()\n", " del self.conn\n", " self.root.current = \"login\"\n", " self.root.ids.chat_logs.text = \"\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "这样程序就搞定了。\n", ">提示:\n", ">测试的时候,`server.py`文件应该持续运行,但是我们的app就不能终止连接了。最终结果就是app停留在登录界面,不再调用`on_connect()`,用户也不能到聊天室界面。\n", ">还有,在Android上面测试的时候,确定你的服务器IP地址,不是`127.0.0.1`,只要局域网设备才这样,在Android设备上不一样。可以用`ifconfig`查询(Windows上是`ipconfig`)。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 客户端交互" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "前面做的Telnet、两个客户端虽然实现方式不同,却可以通信,因为其底层的原理基本一致。\n", "\n", "类似于互联网的处理方式:只要你用HTTP协议,相关的服务器和客户端就可以交互:网页服务器、浏览器、搜索引擎等等。\n", "\n", "协议是更高级的API,与语言、系统无关,应该选一个流行的用。并不是每个网络开发者都熟悉微软2007年发布的Silverlight协议,但大家都知道1991年发布的HTTP。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 增强视觉体验" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "现在app已经可以运行了,我们把聊天窗口改善一下。可以用Kivy的**BBCode**来修饰。\n", "\n", "让我们给每个用户加个颜色,这样方便用户区分所有人。我们同样使用**扁平化UI**的配色方式。\n", "\n", "当前用户发送的信息不会从服务器发给自己,是通过客户端代码添加到对话内容里的。所以,我们要把当前用户名加一个固定的颜色。" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "colors = [\"7F8C8D\", \"C0392B\", \"2C3E50\", \"8E44AD\", \"27AE60\"]\n", "\n", "\n", "class Chat(protocol.Protocol):\n", " def connectionMade(self):\n", " self.color = colors.pop()\n", " colors.insert(0, self.color)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "通过一个无限循序,我们把颜色依次加到用户名上,循环使用。如果你熟悉Python的`itertools`模块,你可以这么写:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "import itertools\n", "\n", "colors = itertools.cycle((\"7F8C8D\", \"C0392B\", \"2C3E50\", \"8E44AD\", \"27AE60\"))\n", "\n", "\n", "def connectionMade(self):\n", " self.color = colors.next()\n", " # next(colors) in Python 3" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "现在,我们再把颜色添加到用户名上,很简单,就是`[b][color]Nickname[/\n", "color][/b]`。" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "for t in transports:\n", " if t is not self.transport:\n", " t.write(\"[b][color={}]{}:[/color][/b] {}\".format(self.color, user, msg))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "`main.py`里面的客户端也同时更新了。我们还要为当前发消息的用户增加一个固定的颜色:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "def send_msg(self):\n", " msg = self.root.ids.message.text\n", " self.conn.write(\"%s:%s\" % (self.nick, msg))\n", " self.root.ids.chat_logs.text += \"[b][color=2980B9]{}:[/color][/b] {}\\n\".format(\n", " self.nick, msg\n", " )" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "然后,我们把聊天记录部件`ChatLabel`的`markup`属性设置为`True`:\n", "```yaml\n", ":\n", " markup: True\n", "```\n", "这样就可以了。" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "## 转义字符处理" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "和HTML一样,用户发送的消息可以会出现转义字符。比如BBCode之类的符号。要解决这个问题,我们可以用Kivy的`kivy.utils.escape_markup`来解决。但是还不是很完整,我们可以稍微调整一下:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "def esc_markup(msg):\n", " return msg.replace(\"&\", \"&\").replace(\"[\", \"&bl;\").replace(\"]\", \"&br;\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "这样所有的Kivy里面的转义字符转变成HTML字符替代,这样遇到这些字符时就会不会发生转义。在`server.py`文件里面,相应的代码需要改变:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "t.write(\"[b][color={}]{}:[/color][/b] {}\".format(self.color, user, esc_markup(msg)))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "在`main.py`里面,实现是类似的:" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": true }, "outputs": [], "source": [ "self.root.ids.chat_logs.text += \"[b][color=2980B9]{}:[/color][/b] {}\\n\".format(\n", " self.nick, esc_markup(msg)\n", ")" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "现在bug修复了,用户可以安全的发从BBCode消息了。\n", "\n", "![chatlast](kbpic/4.7chatlast.png)\n", "\n", ">其实这类bug在互联网产品中很常见。类似于跨站脚本(cross-site\n", "scripting,XSS),可以造成比修改字体更恐怖的结果。\n", ">不要忘了净化所有产品的用户输入,因为用户一不小心用了命令行就麻烦了。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 后续任务" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "这些都只是开始,还有很多必须的事情没做。比如用户名唯一,没有历史记录和离线消息的支持。如果网络质量不好的话,消息总会丢失。\n", "\n", "但是更重要的是,这些问题都可以解决,我们已经有了产品原型。在产品开始阶段,原型是非常具有吸引力,先让轮子转起来,就有了动力;如果你因为好玩而编程,这种感觉更明显,因为你看到了一个可以使用的产品了(相反如果只是一块块代码,不能使用,那感觉很糟糕)。" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 总结" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "这一章我们看到了CS架构的应用开发(其实就网络编程)其实也不复杂。甚至底层的套接字代码也很容易搞定。\n", "\n", "当然,涉及到网络时会遇到很多灰色地带不容易搞定。包括高时延的处理,中断连接的恢复和多节点的数据同步等(尤其是点对点或多主机时,每一个机器只有一部分数据)。\n", "\n", "另一个目前比较新的网络问题就是政治方面的,政府已经开始实施互联网管制,包括出于安全的原因(比如,封杀恐怖主义网络资源)到完全无厘头(封杀教育网站像维基百科,主要的新闻网站或视频游戏网站)。这种连接问题会产生很高的间接伤害,如果CDN(content delivery network)挂了,很多使用CDN链接的网站就不能正常显示了。你懂的。\n", "\n", "但是,只要踏踏实实的坚持下去,一定可以克服重重困难把优质产品发布给客户。而且,Python丰富的特性可以减轻你的负担,本章的聊天app已经充分体现了这点:很多底层的细节都可以通过Kivy和Twisted轻松搞定。\n", "\n", "互联网领域有无限可能,永无止境。下一章我们继续网络相关的尝试,敬请期待。" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.7.7" }, "toc": { "base_numbering": 1, "nav_menu": {}, "number_sections": false, "sideBar": false, "skip_h1_title": false, "title_cell": "Table of Contents", "title_sidebar": "Contents", "toc_cell": false, "toc_position": {}, "toc_section_display": false, "toc_window_display": false } }, "nbformat": 4, "nbformat_minor": 1 }