Skip to content

[中文] Why PyWebIO?

WangWeimin edited this page Dec 31, 2021 · 9 revisions

Why PyWebIO?

这篇文章是写给有Web开发经验的开发者, 在一个具体的场景下介绍了传统Web开发的缺点以及PyWebIO的优势。如果您没有Web开发的经验,可以忽略本文中与Web开发相关的部分。

脚本→Web之难

在日常开发工作中,经常会遇到这样的一个问题,一个功能可以通过一个脚本(控制台程序)快速实现,但为了方便又希望通过Web方式提供服务,比如,添加用户。

将脚本代码转换为Web服务会面临以下几个困难:

  1. 需要编写额外的前端代码来实现界面。
    这里至少要写两个界面,一个是输入表单,另一个是提交表单后经过后台处理产生的结果页。如果原脚本里的输入不能整合到一个表单里(比如需要依据一些输入项的内容来决定一些其他输入项),那么还需要编写更多的表单页面。
  2. 由于http协议的无状态性,需要在各个后端接口之间转递状态,并且需要额外的代码检查状态的合法性。
    比如,当整个业务流程需要提交多个表单时,在传统Web开发中需要通过后端的session机制或前端的<input type="hidden">机制来保存用户之前输入的数据,并且在每个接口中都需要对传递过来的状态进行额外校验。
  3. Web框架的使用逻辑和脚本代码在某些方面不一致,也加大了将脚本代码转换到Web框架中的难度。
    比如控制台程序若要进行一些耗时操作,可以直接阻塞在主线程中,只需要周期性打印些日志就可以;而在Web应用中,很多Web框架对一个http连接的保持时间有限制,耗时操作通常需要异步完成,前端需要定时轮询来实现任务进度的实时展示。

下面会通过一个例子来具体说明。

An example

我们现在需要实现一个答题闯关游戏,游戏规则是:每次随机出题,答对当前题才能进行下一题,当用户答错一题时游戏结束,然后系统显示用户累计答对的题目数量。

题目数据以以下格式提供:

questions = [
    {'question': 'What is a correct syntax to output "Hello World" in Python?',
     'options': ['echo("Hello World");', 'print("Hello World")', 'echo "Hello World"'],
     'answer': 1},
    ...
]

下面我们开始分别通过终端程序、Web应用、PyWebIO应用的形式来实现这个APP。

终端程序

如果使用控制台程序实现这个流程,代码逻辑可以非常清晰。 以下是Python实现:

from random import shuffle

questions = [...]

shuffle(questions)
for cnt, q in enumerate(questions):
    print('Question-%s: %s' % (cnt + 1, q['question']))
    print('Options: \n', '\n'.join(f'[{idx}] {opt}' for idx, opt in enumerate(q['options'])))
    answer = input('Input your answer (only the number of the option):')
    if answer != str(q['answer']):
        print(f'Game Over. You have passed {cnt} questions.')
        break
else:
    print(f'Congratulations! You have passed all the {len(questions)} questions.')

可以看到,代码非常的简洁!只不过应用的UI也非常简陋:

Console app

同时,这个应用也无法被本机以外的其他人使用,为了解决这一问题,我们就需要Web应用了。

Web应用

但如果要将这个游戏实现为Web应用,需要做更多的工作。这里,你可以思考一下如果让你来做一个这样的Web应用,你会如何实现。

具体来说,你需要分别编写html页面和后端接口。 一共需要2个html页面,一个用来展示问题和选项(question.html),一个用来展示游戏结束之后的提示内容。

后端需要编写2个接口,用于展示问题和接收用户提交的回答。接口的Flask实现如下所示:

from flask import Flask, request, session, render_template
from random import shuffle

app = Flask(__name__)

questions = [...]

@app.route('/')
def index():
    question_idxs = list(range(len(questions)))
    shuffle(question_idxs)
    session['question_idxs'] = question_idxs
    session['question_cnt'] = 0

    current = questions[question_idxs[0]]
    return render_template('question.html', question=current['question'],
                           options=current['options'], cnt=0)


@app.route('/submit_answer', methods=['POST'])
def submit_answer():
    cnt = session['question_cnt']
    question_idxs = session['question_idxs']
    current_question = questions[question_idxs[cnt]]
    answer = request.form['answer']
    if answer != str(current_question['answer']):
        return f'Game Over. You have passed {cnt} questions.'

    session['question_cnt'] += 1
    if session['question_cnt'] >= len(questions):
        return f"Congratulations! You have passed all the {len(questions)} questions."

    next_question = questions[question_idxs[cnt + 1]]
    return render_template('question.html', question=next_question['question'],
                           options=next_question['options'], cnt=cnt + 1)


if __name__ == '__main__':
    app.run()

其中,question.html 模版的内容如下。另外,为了简单起见,游戏结束的提示我们直接以字符串的形式返回。

question.html 模版
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Question-{{cnt+1}}</title>
</head>
<body>
<b>Question-{{cnt+1}}:</b><i>{{ question }}</i><br>
<b>Options:</b><br>
<form action="/submit_answer" method="post">
    {% for opt in options %}
    <input type="radio" name="answer" id="options-{{loop.index0}}" value="{{loop.index0}}">
    <label for="options-{{loop.index0}}">{{opt}}</label>
    {% endfor %}
    <br>
    <input type="submit">
</form>
</body>
</html>

在上文的flask app中,我们使用了session来维护状态,需要非常小心地维护session,否则应用容易出现漏洞。

比如你有没有发现这个flask app中实际上也存在一些问题?

问题1: 用户对同一问题可以重复提交答案。根据游戏规则,用户在提交错误答案后游戏结束,但此时如果用户继续向 /submit_answer 接口提交数据会发生什么事情呢? 在我们的应用中,应用同样会接受用户的提交!这也就意味着,用户在回答错误时,可以重新尝试其他回答直到回答正确。对于我们的应用来说这是一个非常严重的漏洞。

问题2: 恶意用户可能在未获取题目时就直接向 /submit_answer 接口提交数据,这会引发异常。虽然在本应用中,这确实不会造成太严重的问题,但是在某些场景下,可能会导致数据库存入空数据等问题。

为了解决这两个问题,需要在游戏结束后重置session,并添加额外的session状态检查代码。代码的修改如下:

@app.route('/submit_answer', methods=['POST'])
def submit_answer():
+   if 'question_cnt' not in session:
+       return "Invalid state!"  
    cnt = session['question_cnt']
    question_idxs = session['question_idxs']
    current_question = questions[question_idxs[cnt]]
    answer = request.form['answer']
    if answer != str(current_question['answer']):
+       del session['question_cnt']
+       del session['question_idxs'] 
        return f'Game Over. You have passed {cnt} questions.'

    session['question_cnt'] += 1
    if session['question_cnt'] >= len(questions):
        return f"Congratulations! You have passed all the {len(questions)} questions."

    next_question = questions[question_idxs[cnt + 1]]
    return render_template('question.html', question=next_question['question'],
                           options=next_question['options'], cnt=cnt + 1)

在我们非常小心地编写了四倍于终端程序的代码量后,终于得到了Web版本的答题游戏:

Flask app

只不过这个界面依然不敢恭维,别担心,我们还可以通过编写CSS样式来美化界面,不过在开始之前我得先评估一下我的发量还支持我写多少CSS(逃)。

到目前,我们已经看到了同样的逻辑在终端程序和Web程序中实现的巨大差异,这时候在回头去看开头"脚本→Web 之难"小节应该就比较容易理解其中的内容了吧。

然而,我们要构建Web应用就必须要承受这样的让人头秃代价吗?答案是否定的,因为你现在有了构建Web应用的新选择——PyWebIO。

PyWebIO的解决方案

PyWebIO可以快速将脚本转换成Web服务,只需要将终端程序版本中的输入输出函数替换成PyWebIO提供的输入输出函数即可:

from random import shuffle

from pywebio import start_server
from pywebio.input import *
from pywebio.output import *

questions = [...]

def main():
    idxs = list(range(len(questions)))
    shuffle(idxs)
    for cnt, idx in enumerate(idxs):
        q = questions[idx]
        answer = radio('Question-%s: %s' % (cnt + 1, q['question']), options=q['options'])
        if answer != q['options'][q['answer']]:
            put_error(f'Game Over. You have passed {cnt} questions.')
            break
    else:
        put_success(f'Congratulations! You have passed all the {len(questions)} questions.')


if __name__ == '__main__':
    start_server(main, port=8080)

PyWebIO版本的代码和终端版本一样简洁,同时又允许多人访问,其用户界面也比Flask版本更友好(更不用说和终端版本相比了):

pywebio app

同时使用PyWebIO编写Web服务不存在上文描述的几种问题,因为从代码编写逻辑上看,PyWebIO应用还是延续了控制台程序的编写方式, 只是应用的交互媒介则由终端变成了浏览器, 也因为如此,比起终端程序,PyWebIO可以输出更多样的内容(比如图片、图表等), 因此PyWebIO非常适合快速构建交互式的Web应用。