12月19, 2019

Python 做 UI 超 easy(6.5)——聊天软件完工,增加发送功能

上一节已经实现了聊天软件的最关键部分:消息的接收和显示。其实我们已经实现了一个单向的聊天工具,如果把 HTTP 调试工具可以算作聊天发送端的话。

当然,这样的作品交差肯定是不行的,今天只剩下最后一哆嗦,继续加油了。

界面调整

一个经典的聊天软件有哪些功能呢?

  • 一个多行文本框显示聊天记录,最新消息追加在聊天记录的最后一行。
  • 一个编辑框,用于输入要发送的消息。
  • 一个发送按钮

这一节几乎没有新知识,调整视窗这种事咱们也是一回生、二回熟、三回旧相识,不多啰嗦,直接在上一节代码的基础上,加代码:

# 继续增加一个 Input 输入消息,一个 Button 发送消息
# ###就显示一个多行文本框,大小是 50 * 5,单位是应为字符,并通过 disabled 禁止用户编辑内容,使其只读
layout = [  
    [sg.Multiline( size = (50, 5), key = "-MSG_SCREEN-", do_not_clear=True, disabled=True)],
    [sg.Input(key="-EDIT-", size = (50, 1))],
    [sg.Button("Send", key = "-SEND-")]
]

注意第二行注释是上一节的遗迹,我们在 sg.Multiline 下面又增加了 sg.Inputsg.Input。注意,在 PySimpleGUI 中一行用一个 list 表示,即使一行只有一个组件,它也得在 list 中,所以这两个组件必须分别在一个 list 中,这样它们才能垂直排列。

直接运行,就可以看到效果,非常简单。

title

已经达到要求了。

实现消息发送

上一节我们用的是 HTTP 调试工具发送消息。现在就需要将发送 POST 请求的功能,增加到当前代码中。

之前已经约定服务端接收数据有这些要求:

  • web 服务端在 IP:PORT 等待请求;
  • 客户端向服务端发送 POST 请求,请求 Url 是 http://IP:PORT/msg;
  • POST 请求的数据是 {"from" : "sender name", "msg" : "what you say"},这是一个 Json 格式的数据。

触发发送事件的是 ”-SEND-“ 按钮,要发送的 msg 是视窗中 ”-EDIT-“ 组件中的文字。至于 IP、PORT 和发送者的名称,暂时硬编码在代码中。

TIPS
在半成品的时候,将临时数据编码在代码中是一种非常方便的做法。 随着功能的完善,这些硬编码的数据会被变量取代。

# HOST 和 PORT 在编写 web 服务的时候,就已经定义过了
HOST = "localhost"
PORT = 8080

# 硬编码发送者姓名
SENDER = "Jiangchuan"

发送消息的时机是 “-SEND-” 按钮被按下之时。立刻想到,发送消息的代码应该在 event == “-SEND-” 的事件处理中。

不过在此之前,先把发送消息的功能,编写为一个函数,不然 “-SEND-” 消息处理代码就会很长,可读性不太好。

TIPS
适当的时候,将一组相关的代码整合为函数,即能提高代码复用,又能使代码整洁。

要发送一条消息,至少需要:

  • web 服务的 ip,web 服务有时也称为 host、
  • port、
  • 发送者的姓名、
  • 及消息正文

所以发小消息的函数应当有 4 个参数,实现如下:

# 在代码开头,导入 requests 库
import requests

def send_msg(host, port, name, msg):
    # 按照约定,POST 数据是这样格式的字典,发送的时候会被转换为 Json 格式的字符串
    msg_obj = {"msg" : msg, "from" : name}

    # 使用 requests 库的 post 方法,向 http://host:port/msg 发送 msg_obj 转换成的 json 字符串,
    # 注意 post 函数的 data 参数就是是 msg_obj 经过 json 库转换后的结果。
    # 这里没有处理发送异常,如果希望完善一些,你应当在调用 send_msg 的时候捕获可能发生的异常。
    r = requests.post("http://{}:{}/msg".format(host, port) , data = json.dumps(msg_obj))

好了,以后我们就可以通过 send_msg,以 name 的身份,向 host:port 上的 web 服务发送消息 msg 了。

下面来处理 "-SEND-" 事件。基本的逻辑是: title

其中,应当捕获 send_msg 可能触发的异常,比如对方的 web 服务联系不上,或者其他什么错误。不处理的话,会有一些问题,但是能节省不少精力,这个工作就留给你来处理了,姑且让我偷个懒。

TIPS
Python 的异常处理可以参考这里

继续上代码,这些代码可以放在 "-TIMEOUT-" 事件处理之后:

    if event == "-SEND-":
        old_sreen = values["-MSG_SCREEN-"]

        # 获得你输入的内容
        your_msg = values["-EDIT-"]

        # 发送消息
        send_msg(HOST, PORT, SENDER, your_msg)

        # 将发送出去的消息,拼接为一条新的本地聊天记录,格式是 you: 消息
        new_msg_line = "{}: {}".format("you", your_msg) 

        # 将新记录追加到聊天记录尾部
        new_screen = "{}{}".format(old_sreen, new_msg_line) if old_sreen != "" else new_msg_line
        # 更新本地聊天记录框
        window["-MSG_SCREEN-"].update(new_screen)
        # 清空编辑框
        window["-EDIT-"].update("")
        continue

运行一下看看效果:

img

太棒了,发送一次消息,可以看到聊天记录里先有一条已发记录,还有一条一摸一样的消息,只不过显示的是 Jiangchuan 发来的,这是新收的消息。

这是为什么呢?现在代码里 HOST、PORT、SENDER 都是硬编码的,消息被发送到自己的 web 服务上,所以自己能收到自己的消息。

询问用户信息

如果两个人希望用这个工具互相沟通,如果每次都要用户自己改代码里的 HOST、IP、SENDER 信息肯定是不合理的。

还记得最开始用过的 One-shot window 吗?此时正是需要它的时候,我们需要一次性的窗口,获得用户自己 web 服务端口、对方的 IP 及 端口,还有用户的姓名。

迅速的设计一个 5 行的蓝图:

hostInfoLayout = [
                [sg.Text("local port"), sg.Input("8080", size=(20, 1), justification="center", key = "-LPORT-")],
                [sg.Text("remote IP"), sg.Input("localhost", size=(20, 1), justification="center", key = "-HOST-")],
                [sg.Text("remote port"), sg.Input("8080", size=(20, 1), justification="center", key = "-PORT-")],
                [sg.Text("your name"), sg.Input(size=(20, 1), justification="center", key = "-NAME-")],
                [sg.Button("OK"), sg.Button("Cancel")]
]

我指定了一些值的默认值,希望可以减少一些用户输入工作。

然后,依次完成以下操作:

  • 创建 window;
  • 调用 window.read();
  • 取得所有 Input 的值;
  • 关闭 window。

这个流程我们应该很熟悉了,在此不再展开,大家直接查看最终源码即可。

通过上面的工作,就可以得到:

  • 本地 web 服务
  • 对方 IP
  • 对方 端口
  • 本地用户名

记得在创建本地 web 服务和发送消息的时候,使用相应的值。

看一下最终效果,开了一对聊天工具:

  • Jiangchuan 在 localhost:8080 接收消息,
  • Lucy 在 localhost:8081 接收消息。

img

对照之前的构想图,更容易理解两个客户端之间的工作原理。

title

一点补充

为什么在我的例子中,我配置的 web 服务是

  • localhost:8080
  • localhost:8081

都用一个端口多方便啊!是啊,但是操作系统不允许同一台计算机多个程序使用相同的 IP:PORT,所以在自己的电脑上 IP 都是一样的,只能通过使用不同的端口绕开这个限制咯。

如果在不同的计算机上运行这个程序,就可以使用相同的端口,因为它们的 IP 肯定不同。

两台机器要用现在的工具通信,要求它们:

  • 要么两台机器都在公网;
  • 要么两台机器都在 同一 局域网;

否则不能互相通信,至于原因在 6.1 有详细解释。

安全性如何?

我们用的微信、QQ 实际上除了聊天双方外,还有腾讯公司作为中间人。某种角度来说,腾讯知道我们所说的每一句话、发的每一段语音、图片,一切的一切。如果你发现只是在微信里聊了几句 Python,网页、知乎都开始给你推送相关广告,你大概也猜得到背后的逻辑吧。

我们做的小聊天工具是真正点对点的(peer to peer),没有中间人,这一点上隐私性比微信、QQ 等 IM 高。

但是我们的通信使用的是明文 HTTP 协议,很容易被有恶意的人截获,比较安全的做法是在通信中使用加密技术,比如使用 HTTPS 代替 HTTP,或者在聊天双方之间建立加密隧道,都是可以的。有兴趣可以了解一些常见的加密技术,尤其是非对称加密。

想作出真正安全的聊天工具,还有很多工作要做。

总结

最近刚好团队招了新人,采购了几本 Python 教材,我的文员惊叹道:“ 吓我一跳,一本书 700 页!”

是的,其实我还没告诉他这书还有上下两册!

title

我回过头一看,也被吓了一跳————真厚!

当真正潜入到一个技术中的时候,才发现它有这么多错综复杂的细节。一个非常简单的聊天工具竟然也写了 5 节!我自己也觉得有点不可思议。

编程确实会牵扯到各种行当的知识。也许遇到问题的时候,很多知识就信手拈来。平时没有觉得有多少,罗列开一看,还真是一大堆让人头疼的东西。这正是长久学习积累的作用,长久坚持的东西,不知不觉就会会带来扎实的回报。

即便这么难的东西,最后不也被我们搞定了么?!做到少数人能做到的事,感觉就很棒。

下一节我打算暂停脚步,稍微总结一下这一段学习到的东西,以求融会贯通,顺便琢磨点有趣的新项目。

明天见咯~

本节的代码在这里 https://github.com/JiangChuanGo/examples/tree/master/PySimpleGUIDemos/messenger_demo

本文链接:http://www.thinkinpython.com/post/PySimpleGUI6_5.html

-- EOF --