Python-开发者落地指南-全-

Python 开发者落地指南(全)

原文:The Well-Grounded Python Developer

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

我们对经验丰富的软件开发者的现代创作感到惊叹。Instagram 提供了一个光鲜亮丽且美丽的体验,而 YouTube 甚至超越了最大的电视观众群体,它仍然感觉你像是系统上的唯一用户。YouTube 和 Instagram 的核心都是 Python。

当你刚开始编程时,很容易看到前面的挑战,觉得它像一座高耸入云的山峰。幸运的是,你不必一跃而起攀登山峰,就像你不必一下子成为自信的 Python 开发者一样。软件开发不需要你成为天才。你不需要成为数学奇才。成为一名优秀的软件开发者需要强烈的求知欲和大量的毅力。

你不必仅仅相信我的话。Python 的发明者吉多·范罗苏姆在为为科学歌唱播客接受采访时说:

主持人:那么你不需要有数学倾向?

吉多:没错。某种逻辑思维倾向和对细节的关注比数学更重要。

所以,如果编程不是以数学为中心,那么你成功需要什么?你需要成千上万的微小且易于理解的构建块。就像通过许多小步骤和大量毅力攀登一座山一样,你可以通过解决成千上万的简单且易于理解的计算机问题,用几行可理解的代码来构建 YouTube。

你如何发现这些构建块?你可以在网上和编程教程中四处游荡,自己拼凑它们,或者,就像登山一样,你可以雇佣一个向导。道格·费尔南和这本书是他的指南。

在这里,你将学习许多构建块。你将了解明确命名事物的重要性。函数get_blog_post_by_id不需要额外的细节来传达其作用,对吧?你会看到如何使用函数将你的代码分组为可重用的块。你将使用 Python 和 Flask 构建表单,在网页上显示 UI。你将使用 Python 的 SQLAlchemy 数据库包从数据库中读取和写入数据,而无需理解 SQL(关系数据库的语言)。

最后,你将拥有由这些许多构建块组成的实用和真实世界的应用。这将是一次愉快的旅程,代码将成为你在软件开发职业生涯中成长时提取示例和灵感的宝贵资源。有了道格和这本书作为你的指南,你将不断攀登,不知不觉中,你将站在山顶。

迈克尔·肯尼迪是一位 Python 爱好者和企业家。他是Talk Python To MePython Bytes播客的主播。迈克尔创立了 Talk Python Training,并作为 Python 软件基金会驻波特兰,俄勒冈州的会员。

前言

在我的一生中,我从事过许多有趣且多样化的工作,并且有幸成为一名软件开发者近 40 年。在这段时间里,我学习并使用过许多编程语言——Pascal、Fortran、C、C++、Visual Basic、PHP、Python 和 JavaScript——并将这些语言应用于多个行业。使用所有这些语言和拥有这些经验让我受益匪浅。

C 语言教会了我如何使用——当然也包括滥用——指针,并激发了我优化应用程序以提升速度的渴望。C++ 教会了我面向对象编程(OOP),这是一种我至今仍然坚定不移的思维方式。PHP 是我第一次接触脚本语言,它教会了我也许不必自己管理一切,比如内存。PHP 还让我第一次涉足网页开发,以及浏览器作为应用平台的初步想法。

自从我发现 Python 以来,几乎已经过去了二十年,它一直是我在应用开发中的甜蜜点。这种语言帮助我明确了关于开发的模糊想法,即应该有一种明显的方式来做事。能够在同一语言中使用多个关于开发的观念——比如 OOP、过程式编程和函数式编程——是非常有价值的。一种语言可以相对容易学习且表达性强,同时似乎没有解决各种问题的上限。

由于我对 Python 感到兴奋,我希望推广它并鼓励其他人加入。这导致我在我所工作的组织中做演讲和教授课程。我还有机会在我家乡附近的一个 STEM 设施教授 Python,面向 8 到 16 岁的孩子。课堂上的教学效果如何很难说,因为我从孩子们那里学到了很多。每一堂课都让我更多地了解到如何以更易于理解的方式呈现材料。通过我需要付出多少努力来阻止孩子们在笔记本电脑上切换到 Minecraft,就可以明显看出我所教授的内容是否有效。

对于演讲和课程工作,我正在编写自己的 Python 材料。我希望做更多这样的事情,这促使我为 RealPython.com 写文章。我为该网站撰写了几篇广受欢迎的文章,这让我感到非常满意。那些文章是我与 Manning 建立联系的方式。一位收购编辑联系了我,我们讨论了为他们编写一本 Python 书的想法以及那会是什么样子。

那些对话的结果就是这本书,它将帮助你踏上成为开发者的旅程。Python 是一个奇妙、表达性强且令人愉悦的工具,值得你随身携带。我已经享受这段旅程很长时间了,并且仍然在享受。这就是这本书的目标,我希望这本书能帮助你实现你的目标。

致谢

我努力使这本书既具有信息性,又令人愉快。但如果是这样的话,那是因为为这本书的创建做出贡献的人们。

曼宁出版社的许多人帮助这本书得以问世。那位邀请我写书的采购编辑、帮助塑造这本书的出版人员、帮助完善它的审稿人,以及在我每一步都给予帮助的发展编辑。他们帮助我导航写作和出版书籍的许多方面,我和他们进行了许多对话,这些对话使我能够在这个过程中保持正确的方向。

向所有审稿人致谢:亚历杭德罗·瓜拉·曼萨纳雷斯(Alejandro Guerra Manzanares)、阿曼达·德布勒(Amanda Debler)、安杰洛·科斯塔(Angelo Costa)、伯纳德·富恩特斯(Bernard Fuentes)、巴格万·科马达(Bhagvan Kommadi)、布兰登·弗里亚(Brandon Friar)、查德·米尔斯(Chad Miars)、克里斯托弗·卡德尔(Christopher Kardell)、丹·谢赫(Dan Sheikh)、丹尼洛·阿布里尼亚尼(Danilo Abrignani)、德尚(Deshuang Tang)、迪纳卡兰·文卡塔(Dhinakaran Venkat)、德里克·戈麦斯(Dirk Gomez)、埃德·安德烈斯·阿维拉·尼诺(Eder Andres Avila Niño)、埃利·梅约斯特(Eli Mayost)、艾瑞克·姜(Eric Chiang)、艾尔纳塔·阿达埃(Ernest Addae)、埃维塔·卡夫卡菲(Evyatar Kafkafi)、埃兹拉·施罗德(Ezra Schroeder)、费利克斯·莫雷诺(Félix Moreno)、弗朗西斯科·里瓦斯(Francisco Rivas)、弗兰基·托马斯-霍基(Frankie Thomas-Hockey)、甘尼什·斯瓦米纳森(Ganesh Swaminathan)、加里·艾伦·奥弗德(Garry Alan Offord)、古斯塔沃·戈梅斯(Gustavo Gomes)、广田宏幸(Hiroyuki Musha)、詹姆斯·J·比莱基(James J. Byleckie)、詹姆斯·马特洛克(James Matlock)、贾尼特·库马尔·安贾里亚(Janit Kumar Anjaria)、豪阿金·贝尔特拉诺(Joaquin Beltran)、约翰·古德里奇(John Guthrie)、约翰·哈宾(John Harbin)、约翰尼·霍普金斯(Johnny Hopkins)、何塞·阿帕布拉扎(Jose Apablaza)、约瑟夫·帕霍德(Joseph Pachod)、约书亚·A·麦克亚当斯(Joshua A. McAdams)、朱利安·波希(Julien Pohie)、卡梅什·加内桑(Kamesh Ganesan)、卡蒂亚·帕特金(Katia Patkin)、基思·安东尼(Keith Anthony)、凯鲁姆·普拉巴瑟·塞纳亚克(Kelum Prabath Senanayake)、金伯莉·温斯顿-杰克逊(Kimberly Winston-Jackson)、库什克·维克拉姆(Koushik Vikram)、库普·西瓦姆(Kup Sivam)、利·哈丁(Lee Harding)、莱昂纳多·塔卡里(Leonardo Taccari)、列夫·维德(Lev Veyde)、卢卡斯·迈尔(Lúcás Meier)、马克-安东尼·泰勒(Marc-Anthony Taylor)、马尔科·卡尼尼(Marco Carnini)、马库斯·盖塞尔(Marcus Geselle)、玛丽亚·阿娜(Maria Ana)、迈克尔·帕丁(Michael Patin)、迈克·巴兰(Mike Baran)、莫哈娜·克里希纳(Mohana Krishna)、穆罕默德·索哈布·阿里夫(Muhammad Sohaib Arif)、纳文·库马尔·纳马奇瓦亚姆(NaveenKumar Namachivayam)、尼诺斯拉夫·切尔凯兹(Ninoslav Cerkez)、帕特里克·雷根(Patrick Regan)、菲利普·贝斯特(Philip Best)、菲利普·帕特森(Philip Patterson)、拉胡尔·辛格(Rahul Singh)、劳尔·穆尔西亚诺(Raul Murciano)、张志强(Raymond Cheung)、理查德·梅森(Richard Meinsen)、罗伯特·库拉戈夫斯基(Robert Kulagowski)、罗德尼·韦斯(Rodney Weis)、罗曼·祖扎(Roman Zhuzha)、罗梅尔·伊安·德·拉·克鲁斯(Romell Ian De La Cruz)、萨姆维德·米斯特里(Samvid Mistry)、桑迪普·达梅贾(Sandeep Dhameja)、桑杰夫·基拉拉普(Sandeep Kilarapu)、萨特杰·库马尔·萨胡(Satej Kumar Sahu)、塞尔吉乌·拉德库(Sergiu Raducu)、尚卡尔·斯瓦米(Shankar Swamy)、斯坦利·阿诺齐(Stanley Anozie)、斯坦凡·图拉尔斯基(Stefan Turalski)、泰迪·哈戈斯(Teddy Hagos)、维迪亚·维纳(Vidhya Vinay)和维托什·多伊诺夫(Vitosh Doynov),你们的建议帮助使这本书更加完善。

我还想感谢萨曼莎·斯托恩,一个我只在虚拟世界中认识的年轻编辑。机器人与超越(Robotics & Beyond)执行董事保罗·查伊卡(Paul Chayka)向我介绍了她。萨曼莎曾是 R&B 的高中生,在书稿进行中自愿担任审稿人。她证明了自己拥有卓越的编辑技巧,诚实而坦率的反馈,以及对我写作中哪些地方有效、哪些地方不有效的清晰见解。

我还想感谢卡明·莫拉利洛(Carmine Mauriello)。我和他已经是几十年的朋友,几次在同一组织中工作过。几乎从一开始,他就告诉我:“你应该写一本书。” 这还不清楚这是否只是他试图让我停止说话的善意方式,但卡明,我依然感激你的鼓励。终于,这本书问世了。

我还想感谢我的父母,他们都是各自领域的优秀作家。母亲鼓励(或者说说服)我在 IBM Selectric 打字机时代参加打字课。这证明是我学过的最好的技能之一。父亲是一位伟大的讲故事者,他教我写作简单陈述句的价值。他也是我见过的最快打字者,在古老的 Underwood 机械打字机上。

最后,我想感谢我的妻子,苏珊,她不断的鼓励、无懈可击的耐心,最重要的是,爱,使这一切成为可能。

关于本书

《扎实的 Python 开发者》旨在帮助初学者跨越成为开发者的差距。它通过两种方式实现这一点——通过展示如何将开发过程与更大的项目联系起来进行思考的方法,以及通过展示如何使用 Python 来完成它。

适合阅读本书的人

本书并不教授 Python;许多其他资源在这方面做得相当出色。实际上,读者应该对 Python 有一些经验,并且有进一步学习这门语言的愿望。如果你有这种感觉,那么我认为这本书有很多东西可以提供。

本书的一个目标就是帮助你成为一名 Pythonista。考虑到这一点,这本书的目标读者是谁呢?我认为有广泛的读者群体会从本书所呈现的材料中受益。

第一种读者是那些了解 Python 编程基础知识的人——如何编写循环和条件语句,以及如何使用变量来完成工作。这位读者编写了存在于单个文件中的实用程序,并知道如何从命令行运行它们。他们可能想要构建更复杂的应用程序,但不知道如何做到这一点。他们可能正在考虑使用 Python 编写一个网络服务器,以及他们需要了解哪些技术才能做到这一点。本质上,这位读者有一个基本的工具集,已经把东西拼凑在一起,并想要构建一些更有意义的东西。

第二种类型是另一种语言的开发者,他们有兴趣扩大他们的技能集以包括 Python。这可能是因为 Python 在应用和兴趣方面都在增长,或者可能是出于他们在当前工作中项目的需要。他们知道如何使用他们目前使用的语言完成任务,并想学习如何以 Pythonic 的方式用 Python 完成类似的工作。

第三种类型可能是与数据科学和大数据分析相关的人。Python 正在成为这个领域的关键参与者,拥有许多为这类工作提供服务的成熟库。本书不会涉及这些库的使用——那将是另一本书的内容,但它将帮助那些参与该领域的人。许多从事数据科学工作的人并不一定是软件开发者。编码是实现目标的手段,是帮助达到目标的工具。通过成为 Python 开发者来更好地使用这个工具并提高其表现力,将把编码从问题域中移除,给他们更多的时间和空间来专注于手头的任务。

本书是如何组织的:一个路线图

本书分为两个部分,基础知识与实地调查。基础知识使用 Python 构建了关于开发过程和思维方式的基础信息。实地调查在此基础上构建了一个复杂的 Web 应用程序。这两个部分都包含多个章节:

  • 基础知识

    • 第一章——“成为 Pythonista”介绍了像开发者一样思考和实现目标的概念。它还介绍了 Python 不仅是一个实现这些目标的可行途径,而且是一个强大的途径。

    • 第二章——“好名字很重要”介绍了开发者命名事物的重要性以及命名空间概念的力量。

    • 第三章——“API:让我们聊聊”介绍了开发者和计算机之间如何“交流”:两者之间的合同,输入的内容以及预期的输出。本章详细介绍了良好 Python 函数的设计和实现。

    • 第四章——“对话的对象”介绍了使用 Python 进行面向对象编程(OOP)。这包括如何定义类以及在实现类层次结构时继承、多态和组合的使用。

    • 第五章——“异常事件”涵盖了 Python 异常及其处理方法:何时何地捕获异常,以及开发者在捕获异常时解决它们的方法。它还讨论了有意引发异常和创建自定义异常。

  • 实地调查

    • 第六章——“与互联网共享”是创建演示 Web 应用程序的开始,该应用程序汇集了前几章的基础知识。

    • 第七章——“以风格行事”通过介绍 Bootstrap 为演示 Web 应用程序的风格设定了基准。它还介绍了使用 Flask Blueprints 实现和维持更大应用程序所需的步骤,以及如何导航和配置应用程序。

    • 第八章——“你认识我吗?身份验证”介绍了对应用程序用户进行身份验证的技术。

    • 第九章——“你能做什么?授权”介绍了用户的授权以及不同类型的授权为用户提供不同能力。它还增加了从应用程序发送电子邮件的能力,以及身份验证如何增加安全性,这可以用来保护应用程序的某些部分。

    • 第十章——“坚持不懈是好事:数据库”是一个有点旁枝末节的章节,因为它讨论了关系数据库以及如何设计、实现和查询它们。它还介绍了 SQLAlchemy 作为使用 Python 对象访问数据库信息的工具。

    • 第十一章——“我有话要说”完成了演示网络应用程序,创建了一个完全功能化的博客平台,用户可以查看和创建内容,以及对该内容进行评论。

    • 第十二章——“我们到了吗?”是本书的最后一章,总结了用户所学的知识和他们进一步学习所期待的世界。

  • 附录

    • 附录——“你的开发环境”涵盖了在各个平台上安装 Python、如何设置虚拟环境以及为什么这样做是个好主意。它还涵盖了安装 Visual Studio Code 作为集成开发环境(IDE)以帮助创建 Python 代码。

根据你在 Python 之旅中的位置,将决定你在书中的起点以及你发现最有价值的内容。如果你处于路径的开始部分,阅读整本书将对你有益。如果你已经走得更远,你可以从实地工作部分开始,并从这里继续。

关于代码

本书包含了来自与本书配套的 GitHub 仓库的源代码示例,包括完整的、独立的列表和正文中的文本。代码以这种固定宽度字体格式化,以将其与其他书籍格式区分开来。一些代码以粗体显示以引起注意。这通常伴随着代码注释来解释加粗的代码。

书中的代码列表已经被重新格式化,以便更好地适应书籍设计的限制。此外,在许多情况下,作为仓库一部分的代码注释已经被移除,以节省页面上的垂直空间并减少视觉杂乱。

你可以在本书的 liveBook(在线)版本中找到可执行的代码片段,网址为 livebook.manning.com/book/the-well-grounded-python-developer。本书中所有示例的完整源代码可在作者的 GitHub 网站上找到,网址为 https://github.com/writeson/the-well-grounded-python-developer,以及 Manning 网站上 www.manning.com/books/the-well-grounded-python-developer

liveBook 讨论论坛

购买《扎实的 Python 开发者》包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,您可以在全球范围内或特定章节或段落中附加评论。为自己做笔记、提问和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/the-well-grounded-python-developer/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 的论坛和行为的规则。

Manning 对我们读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以发生有意义的对话。这不是对作者参与特定数量的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要这本书有售,论坛和先前讨论的存档将从出版社的网站提供访问。

关于作者

Doug Farrell 自 1983 年以来一直在开发软件,尽管他的学士学位是物理学,他还拥有商业艺术 AAS——两个显然相关的领域。Doug 是一位自学成才的程序员,多年来在许多行业中使用了相当多的语言:Pascal、Fortran、C/C++、PHP、Python 和 JavaScript。他自 2000 年开始使用 Python,自 2006 年以来一直是他的主要语言。

Doug 为 RealPython.com 撰写了文章。他还在一个 STEM 设施教书,那里大量使用了他的课程材料。

关于封面插图

《扎实的 Python 开发者》封面上的图像是“帕特莫斯岛上的女人”,或“帕特莫斯多德卡尼斯群岛的女人”,取自雅克·格拉塞·德·圣索沃尔的收藏,1788 年出版。每一幅插图都是手工精细绘制和着色的。

在那些日子里,人们通过他们的着装很容易就能识别出他们住在哪里,他们的职业或社会地位是什么。Manning 通过基于几个世纪前丰富多样的地区文化的书封面来庆祝计算机行业的创新和主动性,这些文化通过像这样的收藏品中的图片被重新带回生活。

1 成为 Pythonista

本章涵盖

  • 程序员和开发者之间的区别

  • 介绍 Python 社区

  • 选择与本书一起使用的 Python 版本

成为开发者是一种不寻常的追求。开发者花费时间从无到有地创造东西,即使如此,也很难描述我们刚刚创造的事物。

你是否曾在聚会上尝试解释编写代码的感觉?即使你是一个足够好的讲故事者,能够让人们不会立即走开,但要达到“啊哈”的时刻,即有人可能知道你在说什么,仍然是一个挑战。这并不是听众的过错。这仅仅是描述开发者身份在客观上很难。

程序员和开发者之间的区别

你可能想知道开发者与程序员有什么不同:

  • 程序员创建可运行的 Python 脚本。开发者构建模块来构建更大的应用程序。

  • 程序员对 Python 的了解足够用来创建小型应用程序。开发者对 Python 的了解足够用来将其作为众多工具之一来构建更大的应用程序。

  • 程序员使用 Python 解决问题,而开发者考虑大局,以及 Python 在该愿景中的位置。

  • 程序员知道如何使用 Python 标准库,而开发者知道如何使用第三方包。

  • 程序员编写可运行的代码。开发者编写易于维护的代码。

  • 程序员可能不知道编码规范,而开发者则依赖于规范和惯用语句来加快他们的开发工作。

  • 程序员知道学习是必要的。开发者将学习视为一生的追求。

计算机是异常静态的设备。开发应用程序是人类用我们能够阅读和书写的语言,以及计算机能够理解的语言来表达我们希望计算机做什么的一种方式。关键在于足够精确,以便让计算机执行预期的操作,而不是其他操作。

人们能够在世界上发挥作用并取得巨大成就,因为我们擅长人类交流的不精确性。我们从上下文、意图、语调和细微差别中获取意义,所有这些都为我们的交流增添了丰富的内容。这些对于计算机来说都是不可能的。计算机需要几乎令人发狂的精确性才能运行。为了表达这种精确性,对细节的关注,耐心去做,以及学习和对新想法保持开放的能力,都是成为一名开发者的重要组成部分。

本书旨在为开发者构建一个技能和工具的基础,这些技能和工具对开发者来说通常很有用。我们将使用这些工具来构建独立的应用程序,以展示它们。

一旦你的工具箱得到了扩展,你将创建一个简单的 Web 应用程序,以便熟悉它所提出的挑战,然后修改该应用程序以包含新功能。每一步都将在此基础上建立知识,以介绍一个或多个新的能力、技术、模块和解决方案。

Python 可以带你到达美好的地方。你只需要迈出第一步。考虑到这一点,让我们开始吧。

1.1 对学习的承诺

学习如何提高技术水平以及使用 Python 进行开发是一项宝贵的技能。努力提升自己成为一名 Python 开发者有两个好处。第一个是能够自信地承担更大的项目,并完成它们,创建一个可工作的系统。第二个是学习的实践。成为一名终身学习者不仅仅是一个吸引人的教育口号;这是成为一名软件开发者的现实。

例如,在我的开发者职业生涯中,我使用过几种语言——Fortran、Pascal、C/C++、PHP,现在还有 Python 和 JavaScript。我学习了一些这些语言,因为它们是我工作的地方正在使用。在其他情况下,语言非常适合手头的任务。我曾经认为自己是一位强大的 C/C++ 程序员,并享受使用它编写应用程序的工作。

然而,我对重新拾起我的 C/C++ 技能并再次进行那种编码没有兴趣。现在,对我来说,Python 是我想要使用的语言的最佳选择。它符合我想要在面向对象编程风格中工作的愿望,但并不限制我仅限于那种风格。Python 的语法和语法清晰且表达力强,以至于我可以思考类似于 Python 代码的伪代码解决方案。

如果软件开发是你的职业,或者你希望它是,请记住,职业生涯是漫长的,变化是持续发生的。致力于学习新技术和语言是解决这两个问题的答案。在这个快速变化的世界里,工作保障非常有限;唯一真正的保障是你能带来的技能。

1.2 达成目标

这本书有一些目标,其中之一——帮助你成为一名更强大的开发者——在书名《扎实的 Python 开发者》中有所暗示。如果你在读这本书,那么我推测这也是你的目标之一。

1.2.1 像开发者一样思考

学习一种编程语言意味着学习该语言的语法和语法:如何创建变量、构建循环、做出决策和执行程序语句。这些是你的基本工具,但像开发者一样思考也意味着知道如何将这些工具组合起来创建一个有用的程序。这个类比在构建更大、更强大的工具方面走得更远。

这个通过使用较小的工具构建更大的工具的过程是像开发者一样思考的关键。通过使用其他东西创建一个东西的步骤最终帮助你看到大局。当你学习如何构建更强大的代码块时,作为一个开发者看到大局意味着理解你试图解决的问题,并在实施解决方案的步骤中在心理上往返。从最小的代码块到更广泛的功能,你将能够跟随成功的路径。

1.2.2 构建应用程序

在开发者的术语中,一个应用是一个提供有用功能和使用界面的完整程序。你已经知道的一个明显例子是 Microsoft Word,这是一个大型桌面应用。Google 的 Gmail 是一个大型网络应用。这些都是提供许多功能的大量应用示例。

有许多较小的应用;例如,如果你熟悉大多数计算机系统上可用的命令行,你可能已经使用过ping命令。这个应用通常被用来确定网络上的另一台计算机是否正在响应ping请求。使用ping是一个简单的故障排除测试,用来查看远程计算机是否在进一步挖掘现有问题之前正在运行。

ping 应用几乎位于像 Word 或 Gmail 这样的应用的对立面,但它是自身领域内的一个完整应用。它提供了一个有用的功能,并在终端窗口的命令行界面中提供了一个用户界面。

开发者还会在其他代码块上工作,这些是代码库。它们提供了有用的功能并有接口,但大部分情况下,它们被需要访问库功能的大型应用所使用。Python 附带的标准模块,通常被称为“内置电池”,是库代码的一个优秀例子。随着我们继续阅读本书,你将创建库模块来在我们的应用中使用。

1.3 使用 Python

对于大多数情况,你迄今为止所阅读的关于像开发者一样思考的内容几乎可以应用到任何编程语言上。那么,是什么让 Python 成为追求像开发者一样思考的绝佳选择呢?正如我在上一节提到的,我认为 Python 为应用开发提供了一个完美的平衡点。让我们来谈谈为什么我认为是这样,并希望你能产生同样的感受。

1.3.1 编程范式

大多数,如果不是所有,日常使用的语言都是从其他语言和编程范式中汲取其能力。Python 是这个俱乐部中的一位优秀成员。如果你在 Python 中做过任何编程,你就会知道它是一种灵活的语言,覆盖了广泛的领域。语言灵活性的一个方面是你可以与之交互的多种方式:

  • 通过循环、嵌套循环、条件评估和过程调用提供的控制流能力,使 Python 成为一种结构化编程语言。

  • Python 是一种过程式语言,你可以创建函数(过程),允许你生成可以在程序的其他部分重复使用的代码块。

  • 你可以使用基于类的面向对象编程(OOP)进行编码,它捕获状态信息以及操作该状态的相关代码。

  • 虽然 Python 严格来说不是一种函数式语言,但它提供了允许你以那种方式编程的功能。Python 中的函数是一等对象,可以像任何其他对象一样传递。这个特性是函数式编程所必需的,当以那种风格工作时,Python 提供的这个特性非常有用。

  • 事件驱动的程序,例如窗口化的图形用户界面应用程序——其中事件决定了程序的控制流程——使用 Python 完全可行。

Python 可以应用于这些所有范式,以解决编程问题并创建应用程序。

1.3.2 创建可维护的代码

当你创建一个应用程序时,你期望它会被使用,这意味着它将有一个生命周期。在这个生命周期中,测试不一定总能揭示的缺陷将出现在你的代码中。即使你是应用程序的唯一用户,你对它的使用方式或使用环境的变化也可能揭示你可以解决和改进的问题。PyTest 模块([docs.pytest.org/en/7.2.x/](https://docs.pytest.org/en/7.2.x/))是一个强大的框架,可以帮助测试你开发的应用程序。

如果其他人使用你的应用程序,其需求将会改变。需求的变化意味着需要对现有代码进行修改以添加新功能。

在软件开发的世界里,没有什么是比变化更恒定或发生得更快的事情。程序代码被阅读的次数比编写的次数多,你今天编写的代码随着时间的推移将会发生变化。如果你在很短的时间内回到自己的代码,你会惊讶于你需要阅读多少自己的工作才能回到它被创建的上下文中。如果你在一个团队中工作,并且团队中的其他人将修改你的工作,那个人将根据你的代码的可维护性和可读性来祝福或诅咒你。

编写可维护的代码是值得追求的开发者能力。采用一种编码风格并持续使用它对于实现这一目标大有裨益。使用智能且具有意义的变量名、函数名和类名非常重要。我坚信没有任何编程语言,即使是 Python,能够完全自文档化。阐明代码某部分意图的注释对于理解代码的目的和意图大有帮助。

编写可维护代码的另一个重要方面是使其灵活。很难预测你创建的函数和类在应用程序开发的后期可能会如何被使用。

一个简单的例子是一个执行一些复杂计算、格式化结果然后将格式化后的结果打印到标准输出的函数。该函数未来的使用严重受到其当前实现,包括打印输出的限制。很可能,由于这个原因,它不能用于其他任何事情。如果解释一个函数的功能有一个“和”在解释中,那么它应该实现为两个函数。重构示例创建两个函数——一个执行复杂计算并返回原始结果,另一个格式化结果。

第二个用于格式化数据的函数可以在应用程序的后期用于将结果格式化和输出到目标设备。通过将格式化和输出留到需要时再进行,输出可以被定向到任何设备——一个显示屏、一个网页、一个打印机,或者可能是对 API 调用的响应。复杂的计算函数保持不变。

1.3.3 性能

任何编程语言的运行时性能都是一个经常被讨论、高度敏感且复杂的话题。Python 经常与其他语言,如 C 和 Java,在执行性能方面进行比较。除了关于这个或那个更快的一般性陈述之外,这些比较往往变得更加复杂。

正在比较的是什么——CPU 速度还是内存速度?是如何进行测量的?基准软件是否针对一种语言进行了优化,而不是另一种语言?基准测试是否充分利用了两种被比较语言中的高效编码实践?

虽然听起来有些轻率,但我对这一切并不十分关心。这并不是说我不在乎性能(我们稍后会谈到),但关于这种语言比那种语言快多少的争论并不是一个值得参与的争论。

计算机已经远远超过了在性能计算中考虑 CPU 周期和内存访问时间的点。借用一句商业术语,优化你最昂贵的资源

是最昂贵的资源。如果你是一家公司的软件开发人员,那么你与他们的计算机资源连接起来是最昂贵的资源。作为开发人员优化你的性能至关重要,如果你能迅速将你的大局观转化为运行的代码,你就变得无价。如果你能将一个想法编码并快速运行,提高上市时间,那将是一个巨大的胜利。这正是 Python 发光的地方。

这并不是说我不关心性能。当我最初接触编程时,我痴迷于速度,并会不遗余力地从我的代码中削减 CPU 周期。在这个过程中,我学到了很多关于什么是重要的,什么不是重要的。

在开始任何优化工作之前,你应该做的第一件事是确定它是否真的有必要。你的应用是否需要满足速度要求?如果是的话,是否存在一个衡量应用速度是否足够的指标?如果是,那么这些问题的答案确定你的应用已经足够快,那么你就已经找到了优化时间的终极目标——零。

另一方面,如果确定你的应用确实需要更快,那么你需要采取第二步。这一步是分析应用以测量它花费时间的地方。

拥有这个度量标准在手,你可以应用代码优化的 90/10 规则。这个规则指出,一个应用执行时间的 90%花在了 10%的代码上。这个规则当然是一个概括,但它确实为你提供了优化应该追求的方向。专注于除了应用花费大部分时间的 10%代码之外的其他部分,是浪费了时间,而且不会提高你应用的整体速度。

任何优化工作都需要迭代地进行,并与分析同步。这会告诉你你的优化努力是否在取得改进。它还将帮助你决定你所做的改进是渐进的还是有数量级的提升。性能的小幅提升需要与代码的复杂性相平衡。

最后,要知道何时放弃。有了你应用需要达到的性能指标目标,你就会知道何时停止优化并发布。发布是一个不容忽视的功能。

1.3.4 语言社区

当前最流行的编程语言拥有庞大且活跃的社区,人们愿意与他人分享他们的经验、问题和专业知识。Python 社区尤其欢迎,几乎没有争吵或欺凌。这个社区对于编程新手以及解决新问题的老手来说都是宝贵的资源。

小贴士:作为一名开发者,你成为了一个社区的一部分,Python 社区尤其是一个好的社区。参与其中,做出贡献,倾听,并为这个社区增添价值。这对每个人,包括你自己,都会有好处。

非常常见,当解决 Python 谜题时,你会发现其他人之前已经解决过类似的谜题并发布了解决方案。Python 包索引(https://pypi.org/)是构建应用和寻找帮助该过程的库和模块的无价资源。

除了在 Google 上搜索 Python 帮助之外,这里有一份有用的 Python 资源列表:

1.3.5 开发者工具

作为一名开发者,你的一个目标是以尽可能少的障碍将你的思想和想法从大脑中转移到 Python 代码文件中。一个适合你的好键盘、适当的照明、一个不错的屏幕——所有这些都有助于你试图完成的工作的流程。有许多优秀的编辑器可以识别 Python 代码和语法高亮,在你编写代码时帮助你更容易地找到错误和关键词。

一个好的编辑器是一个重要的工具,但除此之外,一个好的 IDE(集成开发环境)更为重要。IDE 在编写代码时比编辑器更进了一步。它不仅将有一个带有语法高亮的良好编辑器,而且还将了解语言本身。这为你编写代码时提供了额外的帮助,通常称为 IntelliSense。IntelliSense 以交互方式提供代码补全辅助,重构现有代码,符号名称信息和用法,以及更多。

一个好的 IDE 还应提供的是调试器。调试器允许你以交互方式运行程序并设置断点。断点是你可以在程序行上设置的标记,当程序尝试执行该行时,代码将停止运行。当程序暂停时,你可以检查当前作用域内的变量,并查看程序在那个点正在做什么。你甚至可以修改变量的值,这将影响从那个点开始的执行。你可以从断点单步执行代码,逐行跟踪程序的行为。你甚至可以进入函数调用并跟踪其内的行为。

能够调试程序是一个宝贵的工具和技能,它远远超出了在代码中插入print()语句来尝试了解内部发生情况。Python 拥有独立的调试工具以及成熟且强大的集成开发环境(IDE):

  • 微软的 Visual Studio Code 是一个高级源代码编辑器,具有扩展功能,使其成为 Python 的完整 IDE。它在 Windows、Mac 和 Linux 平台上都可用,如果你在多台计算机上工作,这将是一个优势。它也免费下载和使用,是我用来开发本书示例代码的工具。

  • PyCharm 是 JetBrains 提供的一系列开发工具之一,也是一款商业 Python IDE。它也具有语法高亮、智能感应、强大的调试器以及用于与数据库和源代码控制系统集成的工具。它是用于 Python 代码和应用程序开发的强大工具,可在 Windows、Mac 和 Linux 上运行。

  • WingIDE 是另一个功能强大的商业 Python 集成开发环境(IDE),具有语法高亮和智能感应功能,以及一个具有在数据科学工作中有用的功能的先进调试器。此平台也适用于 Windows、Mac 和 Linux。

  • Python 标准库附带一个名为 pdb 的交互式调试器。它提供了前面列出的 IDE 调试器提供的功能,但来自终端窗口。

1.4 选择要使用的 Python 版本

本书中的代码基于 Python 版本 3.10.3。如果你相对较新接触 Python,你可能知道目前存在两个主要的 Python 版本——2.* 和 3.。3. 版本已经存在很长时间了,自 2008 年 12 月以来。这个版本在用户中取得认可花了一段时间,因为那些用户依赖的库和框架与这个版本不兼容,所以他们继续使用 2.* 版本。那个时期已经过去了很久,没有合法的理由在新项目中使用除 3.* 版本以外的任何版本。

从这个时间点开始,Python 3.* 版本将具有更新的功能、最新的语法和更多的开发者支持。这也意味着重要的库和框架将停止对 2.* 版本的支持。这意味着使用 Python 2.* 开发的程序将不得不将那些库和框架的使用锁定在将不再获得新功能或错误修复的旧版本上。这一点对于安全问题尤为重要。

此外,Python 2.* 版本已于 2020 年 1 月 1 日达到 EOL(生命终结)。这意味着核心 Python 开发团队已经完全停止支持那个分支。核心开发者通过这种干净的断开,使他们摆脱了为了继续支持 2.* 而做出的某些妥协。

最后,我认为这一点非常重要,整个 Python 社区已经转向了 3.* 版本。这意味着文档、文章、书籍以及论坛上的问题和答案将不再关注旧版本,而更多地关注新版本(们)。

作为一名开发者,这是一个重要的问题:首先,作为一名开发者需要了解的一切内容太多,无法一次性全部记住。这使得找到相关信息的准确性变得至关重要。其次,变化的速度持续且迅速,试图了解一切几乎是一种徒劳的行为。对于开发者来说,理解你需要和想要做什么,然后能够找到如何去做,这要更有用得多。毫无疑问,这比成为一个事实目录要强得多,这些事实几乎与你学习它们的速度一样快就会过时。

1.5 结束语

我明白你可能觉得成为开发者的范围让你感到不知所措。在某种程度上,你是在仰望夜空,试图全部吸收。相信我,我经历过。

我的愿望是,开始阅读这本书能给你一个小望远镜,让你观察那无尽的夜空,缩小你的视野,并展示我们即将前往的方向的更多细节。我希望这能激发你继续在开发旅程中迈出下一步的欲望。

摘要

  • 成为开发者意味着拓宽你对当前问题的看法,并学习你使用的工具如何相互作用来解决更大的问题。

  • Python 是一个作为开发者学习和工作的优秀编程语言选择。将其作为你工具箱中舒适的一部分是一种技能倍增器。

  • Python 语言社区是友好且乐于助人的。成为该社区的一员是迈向开发者旅程的良好一步。

  • 俗语“拙匠叹工具”只说对了一半;另一半是,一个明智的工匠会选择他们的工具。Python 开发者空间拥有丰富的强大工具,从中可以选择。

第一部分:基础

您已准备好成为一名 Python 开发者,并即将踏上这段旅程的第一步。本书的开头是关于磨练您的 Python 技能,以及如何将新的想法和行为添加到您使用 Python 的方式中。

在第二章中,您将获得更广泛的视角,了解如何命名事物的重要性。您还将了解命名空间是什么,以及为什么它们在 Python 中如此出色且得到良好支持。

第三章将向您介绍应用程序编程接口,或 API,这是开发者和计算机连接的地方。面向对象编程(OOP)是第四章的主题。您将学习如何在 Python 中使用它以及它如何有助于您的设计和实现过程。

在第五章中,您将了解如何处理代码中的异常事件,包括生成您自己的异常。避免不想要的异常很重要,但在它们发生时正确处理它们则更为重要。在此之后,您将准备好将您的 Python 技能应用于一些实地工作,在那里您将把所学知识结合起来,创建一个博客网络应用程序。

2 那是个好名字

本章涵盖

  • 名称

  • 命名约定

  • 命名空间

我们给事物和概念取的名字帮助我们在这个世界中导航并与分享它们的其他人沟通。在软件开发的世界中,名字的重要性更为重要。编程语言有关键字、语法和语法定义,通常是一组常用语言的子集。在 Python 的情况下,这种语言是英语。

对于编程语言来说,这意味着我们使用一组规定的关键字、语法和语法定义程序,这些程序最终会运行。然而,在那些程序中命名元素完全在你自己的控制之下,因为你可以从丰富的英语单词和短语中选取来命名你在程序中创建的项目。你甚至可以使用无意义的字符序列,如果这适合你的话。但你应该这样做吗?

“在计算机科学中,只有两件难事:缓存失效和命名事物。”

这句话归功于 Phil Karlton,他是 Netscape 的一名程序员,也是第一个广泛使用的网络浏览器的开发者。抛开缓存失效不谈,你可能正在想,“命名事物有什么难的?”让我们来找出答案。

2.1 名称

在我刚开始编写代码的时候,我参与的一个系统是基于帕斯卡的。这是我第一次了解到一种允许在程序中命名变量时几乎有无限选择的语言。团队中的另一位年轻小伙子创建了两全局变量来测试真和假。他给它们命名为cooluncool。当时,我们都觉得这很有趣,在编写条件语句和测试函数返回值时,这让我们感到一些乐趣。

随着时间的推移,这些变量名遍布代码中,失去了它们的幽默感,变得更加难以考虑和维护。cooluncool的含义是什么?如果你不知道符号背后的实际值,这些含义是不同的,还是它们可能更多地与英语中单词的使用相一致,这在许多方面暗示了一个范围的意义?

命名事物是你和 Python 共享事物身份的一种方式。通常,这意味着你想要唯一地标识一个事物,使其与程序中所有其他命名的事物区分开来。例如,美国的社会保障号码是分配给人们的,以便他们在国家的使用环境中唯一地识别自己。这个独特的数字序列帮助人们获得就业、纳税、购买保险以及进行所有需要国家唯一标识符的其他活动。

这是否意味着社会保障号码是一个独特事物的良好名称?其实不然。除非你能访问使用该号码的系统,否则它完全是不透明的。它传达不了任何关于它所标识的事物的信息。

让我们将这个唯一命名的想法提升到另一个层次。存在一种称为通用唯一标识符(UUID)的标准标识符。UUID 是一系列字符,在所有实际用途中,它在整个世界上都是唯一的。一个示例 UUID 看起来像这样:

f566d4a9-6c93-4ee6-b3b3-3a1ffa95d2ae

你可以使用 Python 的内置 UUID 模块根据 UUID 值创建有效的变量名:

import uuid
f"v_{uuid.uuid4().hex}"

这将生成一个有效的 Python 变量名,如下所示:

v_676d673808d34cc2a2dc85e74d44d6a1

你可以用这种方式创建变量名来唯一标识你应用程序中的所有事物。这些变量名将在你的整个应用程序和已知世界中都是唯一的。

以这种方式命名变量也会是一个完全不可用的命名约定。变量名传达了关于其标识的事物的绝对信息。这样的变量名也特别长,难以记忆,且使用起来不方便。

2.1.1 命名事物

命名事物不仅关乎唯一性,还关乎将信息附加到命名的事物上。尝试为分配的名称提供意义,或作为事物使用方式的指示,为开发 Python 程序提供了非常有用的元信息。例如,如果你将变量命名为t而不是total,你将不得不检查周围代码的上下文来了解t是什么,而total的含义则提供了对变量如何使用的理解。

小贴士:创建有用的变量名需要付出努力,但对于开发者来说,这是一项值得付出的努力。随着时间的推移,你会发现变量名很难更改。这是因为随着应用程序的开发和使用,对现有变量的依赖性会增加。选择好的变量名可以避免将来需要更改名称。

根据之前的 UUID 示例,你给事物命名的长度也与编写代码的努力程度相关。编程确实涉及大量的输入,这意味着意义和简洁之间的平衡很重要。

你突然处于一个位置,整个语言都是你寻找用于命名事物的单词和短语的狩猎场。你的目标是找到既附加元信息又足够简短,不会妨碍编写或阅读程序代码的单词。这限制了你在命名事物时可以或应该做的事情。就像一个画家在一个有限的色彩调色板上工作一样,你可以选择感到沮丧或在这个限制内发挥想象力,用艺术性和创造力构建一些东西。

你将要编写的许多程序将包括遍历事物集合、计数和将事物相加。以下是一个遍历二维表的代码示例:

t = [[12, 11, 4], [3, 22, 105], [0, 47, 31]]
for i, r in enumerate(t):
    for j, it in enumerate(r):
        process_item(i, j, it)

这段代码功能完全正常。变量 t 是一个包含列表的 Python 列表,它代表一个二维表格。process_item() 函数需要知道项目在表格中的行和列位置——即 it 变量——以便正确处理它。变量 tijrit 虽然完全可用,但并没有给读者提供关于它们意图的信息。

你可能会认为这个例子中的代码没有多大问题,但想象一下,如果每个 for 循环调用之间有更多行代码。在这种情况下,tijrit 变量的声明在视觉上与它们的用途分离。读者可能需要回过头来找到声明以理解变量的意图。记住,读者可能是你六个月后的自己,那时意义和意图可能不再那么清晰。以下是代码的更好实现:

table = [[12, 11, 4], [3, 22, 105], [0, 47, 31]]
for row_index, row in enumerate(table):
    for column_index, item in enumerate(row):
        process_item(row_index, column_index, item)

代码已经更改,因此 t 现在是 tableirow_indexjcolumn_indexrrow,而 ititem。变量名称表明了它们包含的内容以及它们预期用途的意义。如果变量声明与它们的用途之间有大量代码,读者仍然可以快速推断出变量的含义以及如何使用它们。

开发中另一个常见的操作是计数和创建总计。以下是一些简单的例子:

total_employees = len(employees)
total_parttime_employees = len([
    employee for employee in employees if employee.part_time
])
total_managers = sum([
    employee for employee in employees if employee.manager
])

你可以在前面的例子中看到一些相当好的命名约定。名称 employees 给变量赋予了意义。使用复数 employees 表明它是一个可迭代的集合。它还表明集合中包含一个或多个代表员工的事物。列表推导式中的变量 employee 表明它来自 employees 集合的单个项目。

变量 total_employeestotal_parttime_employeestotal_managers 通过在名称中使用 total 来表明它们所指的内容。每个变量都是某种事物的总计。每个变量名称的第二部分表明了被计数的事物。

除了数值计算外,你经常会处理已经具有名称的事物,比如公司、社区或群体中的人。当你收集用户输入或按名称搜索某人时,一个有用的变量名称会使你更容易在代码中思考你所代表的事物:

full_name = "John George Smith"

根据你编写的代码的目的,这可能是一个完全可接受的变量名称来表示一个人的名字。通常,当与人们的名字一起工作时,你需要更多的粒度,并希望将一个人的名字分解表示:

first_name = "John"
middle_name = "George"
last_name = "Smith"

这些变量名称也工作得很好,并且像 full_name 一样,给变量名称赋予了它们所代表的意义。这里还有一个变体:

fname = "John"
mname = "George"
lname = "Smith"

这个版本采用了一种变量命名的约定。这种约定意味着你选择一个模式来创建人的变量名。使用约定意味着读者必须知道并理解正在使用的约定。在先前的例子中,这种权衡是减少了输入量,但变量名仍然有清晰的意义。它也可能更具有视觉吸引力,因为变量名在等宽编辑字体中垂直排列。

采用约定是提高在变量命名约束下生产力的一个技术。如果你觉得简写命名约定更具有视觉吸引力,这有助于在视觉解析代码时识别模式和识别错误。

小贴士:基于这些约定建立习惯有助于减轻作为开发者的认知负担。你可以更多地思考你试图解决的问题,而不是思考如何命名事物。

2.1.2 命名实验

你可能不记得,但在个人电脑的早期阶段,它们有很小的硬盘。早期的操作系统也没有目录或子目录的概念;硬盘上的所有文件都存在于一个全局目录中。此外,文件名限制为八个字符,点字符(.)和三个字符的扩展名,通常用来表示文件包含的内容。

由于这个原因,发明了一些奇特且复杂的文件命名约定来保持唯一性并防止文件名冲突。这些命名约定是以逻辑上有意义的文件名为代价的。一个可能的例子是 1995 年 10 月创建的简历文件,可能如下所示:

res1095.doc

解决这个问题的方案是向操作系统添加对命名子目录的支持,并移除文件名字符长度限制。现在每个人都很熟悉这一点,因为你可以创建几乎无限深的目录和子目录结构。

实验

这里有一个你被要求满足的规范:你工作的会计部门要求所有费用报告都使用相同的文件名:expenses.xlsx。你需要创建一个目录结构,以便所有你的expenses.xlsx文件可以存在,并且不会相互冲突或覆盖,以便保存和跟踪这些费用文件。

约束是要求所有费用报告文件具有固定的文件名。隐含的约束是,无论你设计什么样的目录结构,它都需要适用于你工作产生的尽可能多的费用报告。创建子目录的能力是你用来帮助解决这个问题并保持费用报告文件分离的工具。

可能的解决方案

任何解决方案都取决于你为了完成工作需要创建多少费用报告。如果你是一名初级软件开发人员,你可能一年只出差几次。在这种情况下,你只需要提供粗粒度来保持你的expenses.xlsx文件分开。这种简单的结构将所有费用报告收集在名为expenses的单个根目录下(图 2.1)。每个费用报告都存在于以创建费用报告的完整日期命名的目录中。使用 YYYY-MM-DD 的日期格式,在许多操作系统中,当显示时,目录会以有用的时间顺序排序。

图片

图 2.1 管理费用报告的简单目录结构

然而,如果你是一名销售工程师,你很可能一直在出差,并且可能每天要见多个客户。这改变了你处理约束的方式,需要你的目录结构支持更多的粒度来保持所有expenses.xlsx文件分开。对于销售工程师的一个可能解决方案是使用年、月、日和客户名称值作为子目录。这样做可以让你在每天访问多个客户时保持expenses.xlsx文件的独立性。这创建了一个约定,即路径中每个特定expenses.xlsx文件的每个部分都具有意义和值。图 2.2 展示了这种结构。

图片

图 2.2 提供更多细粒度文件分离的更复杂的目录结构

根据之前的实验,可能不明显,但你创建的是具有意义和约定的变量名。查看特定费用报告的目录路径。你已经创建了命名空间,每个命名空间都缩小了其包含内容的范围。从左到右阅读路径,你会看到路径中由/字符分隔的每个部分都在前一个上下文中创建了一个新的、更窄的命名空间(图 2.3)。

图片

图 2.3 目录路径创建了一个命名空间层次结构。

假设你是被授权制定费用报告命名规范的会计。作为会计,你需要保存所有员工提交的费用报告。你将面临与生成费用报告的员工相同的约束,但还要额外处理保持所有员工费用报告相互区分和独立的问题。

创建一个目录结构来处理增加的复杂性可能包括部门和员工的更高层次抽象。创建一个提供这种粒度的目录结构来跟踪和保存所有员工费用报告是可能的。思考如何创建结构使得会计部门重新思考文件命名要求和约束,并设计一个更好的系统变得清晰。

2.2 命名空间

命名空间创建了一个包含其他命名事物的抽象,包括其他命名空间。你居住的城市或镇的名字就是一个例子。城市名称提供了一个命名空间,包含该城市所有居住的人。城市名称本身可能不是唯一的,但在它所在的层次结构(县、州等)的上下文中,它就是唯一的。

进一步来说,人们居住的街道和道路都有名字。街道和道路名称成为城市命名空间内的一个命名空间。例如,美国各地有许多名为“Main Street”的街道。然而,每个城市通常只有一个 Main Street。

这个命名空间的层次结构创造了美国邮寄地址的约定。珍妮特·史密斯在帝国大厦工作的完整地址可能如下所示:

Janet Smith

Empire State Building, Suite 87A

20 W 34th Street

New York, New York 10001

按照惯例,从下到上读取,邮寄地址命名空间的范围会越来越窄。软件开发者可能会删除冗余信息,并以左到右的形式表示这个地址,就像之前的目录实验一样:

10001|20 W 34th Street|Empire State Building|Suite 87A|Janet Smith

在这里,城市和州已经被删除,因为邮政编码包含了这些信息。命名空间字段已经被 | 字符分隔,因为这个字符不会出现在地址的文本中。从左到右继续,你将到达最终的叶节点,即地址适用的个人。

提示:世界上充满了命名空间,因为这是一种有用的约定,有助于我们组织信息。这种有用性适用于我们想要在 Python 应用程序中组织的信息。

就像目录结构实验一样,从左到右读取,每个独立命名空间包含的信息范围会越来越窄。同样,就像目录结构层次一样,每个命名空间的位置遵循一种约定,赋予每个命名空间特定的意义。

2.3 Python 命名空间

Python 编程语言提供了创建命名空间的能力。命名空间在处理命名变量的约束、赋予它们意义、保持它们相对简短以及避免冲突时,为你提供了大量的权力和控制。你通过在命名空间中放置变量名来实现这一点。在你创建自己的命名空间之前,让我们看看语言提供的那个。

2.3.1 内置级别

当 Python 开始运行应用程序时,它会创建一个 builtins 命名空间,其中 builtins 是 Python 中的最外层命名空间,包含你可以在任何时候访问的所有函数。例如,print()open() 函数存在于 builtins 命名空间中。

你可以通过在 Python 交互式提示符中输入以下命令来查看 builtins 命名空间中的内容:

>>> dir(__builtins__)

这个命令在 __builtins__ 对象上运行 dir(目录)命令。你将看到所有在 Python 任何地方都可以访问的异常和函数列表。

你可能没有想过像 print()open() 这样的函数存在于一个命名空间中,并且你不必这样做。当你更多地了解创建自己的命名空间以及其中对象的范围时,这个想法是有用的。

在处理 builtins 命名空间时,有一点需要注意:在命名空间中用你自己的东西覆盖一个对象是完全可能的。例如,你可以定义一个像这样的函数:

def open(...):
    # run some code here

创建这样一个函数是完全可以接受的;然而,这样做的一个副作用是会覆盖掉在 builtins 命名空间中已经定义的 open() 函数。对于你正在编写的程序来说,将你的函数命名为 open() 可能完全合理,但覆盖 Python 的 open() 函数并使其不可访问,可能并不是你的本意。

你可以通过以下方式创建你的函数来处理这个问题:

def my_own_open(...):
    # run some code here

代码是可行的,但你为了避免你的函数名与 Python 的 open() 函数冲突而牺牲了简洁和简单的意义。使用命名空间提供了一个更好的解决方案。

2.3.2 模块级别

你创建的启动程序运行的 Python 程序文件被认为是整个程序的入口点。当它启动时,builtins 命名空间中的对象被创建并可在你的 Python 程序的任何地方使用。在 Python 中,一切都是对象——变量、函数、列表、字典、类——一切。

你创建并命名的任何内容也是主程序文件中的一个对象,并且有可能与 builtins 以及你创建并命名的其他对象发生冲突和覆盖。你可以,也应该避免这种情况。

将你的程序代码拆分成包含逻辑分组功能的多个文件是一个有用的约定。这样做有以下好处:

  • 将类似的功能集中在一起,使其更容易考虑

  • 防止程序文件变得过长,难以合理编辑和管理

  • 创建命名空间

每个 Python 代码文件都会为你创建一个命名空间。假设你创建了两个名为 add() 的函数,它们具有不同的行为,并且你创建了一个 main.py 文件,其外观如下:

def add(a, b):
    return a + b

def add(a, b):
    return f "{a} {b}"

print(add(12, 12))
print(add(12, 12))

当你运行这个程序时,它可能不会按你想象的方式运行。在代码中无法表明在 print (add(12, 12)) 语句中调用的是哪个 add() 函数。当 Python 执行此代码时,它定义了第一个 add() 函数,然后立即用第二个函数重新定义它,覆盖了它并失去了对第一个定义的访问。

两个函数的行为不同;第一个在两个参数上执行数学加法,第二个在两个参数上执行专门的字符串加法(连接)。然而,在 Python 看来,函数的名称是区分特征。并且由于它们都在同一个命名空间中定义,第二个函数会覆盖第一个并具有优先级。

要使两个add()函数都存在,你需要创建一个命名空间,可以将其中一个add()函数放入其中。为此,创建一个如下所示的utility.py文件:

def add(a, b):
    return f"{a} {b}"

然后将你的main.py文件更改为以下内容:

import utility 

def add(a, b):
    return a + b

print(add(12, 12))
print(utility.add(12, 12))

当你运行main.py文件时,你会得到预期的输出:

24
12 12

创建utility.py文件将两个add()函数定义分开,这样它们都可以存在。在main.py文件中,导入实用程序的语句告诉 Python 将utility.py文件中的所有对象拉入一个名为utility的新命名空间。

请注意,通过导入文件创建的命名空间会基于文件的基本名称添加一个命名空间,这是默认行为。你可以通过以下方式覆盖此默认行为:

import utility as utils

此语句告诉 Python 将utility.py文件中的所有对象拉入一个名为utils的命名空间。如果你想要导入两个具有相同名称的模块但为每个模块维护一个唯一的命名空间,能够具体地别名命名空间可以是一个有用的功能。

还可以在导入功能时遮蔽命名空间。使用你当前的main.py示例,它这样做:

from utility import *

def add(a, b):
    return a + b

print(add(12, 12))
print(utility.add(12, 12))

代码告诉 Python 将utility.py文件中的所有对象拉入当前命名空间。这个程序现在有一个错误,因为实用程序命名空间不再存在,所以print(utility.add(12, 12))语句不起作用。从打印语句中移除utility可以使程序工作,但你回到了原始问题的变体。在utility.py文件中定义的add()函数被在main.py文件中定义的add()函数所遮蔽。因此,在导入文件时通常不建议使用from import *`形式。

能够基于文件创建命名空间是有用的,但 Python 的支持更进一步。通过利用文件系统目录结构,你可以创建命名空间层次结构。就像之前的目录结构命名实验一样,这为你提供了更多创建层次结构意义和范围的工具。

如果你将你的例子进一步扩展,你可能会决定更具体地说明你创建的功能。utility.add()函数是针对字符串处理的,那么为什么不使其更清晰呢?

在与你的main.py文件相同的文件夹中创建一个名为utilities的新目录。将utility.py文件移动到utilities目录,并将其重命名为strings.py。你现在有一个如下所示的目录层次结构:

utilities/strings.py

这就像目录结构实验一样增加了意义;utilities 表示目录下的所有内容都被视为工具。

在创建目录层次结构以包含功能时,需要记住的是需要创建一个 __init__.py 文件。这个文件必须存在于每个目录中,以便让 Python 知道该目录包含功能或其路径。当 __init__.py 文件存在于一个目录中时,该目录就是一个 Python 包。

通常,__init__.py 文件是空的,但不必如此。文件内的任何代码都会在包含它的路径作为导入语句的一部分时执行。

根据这一点,在你的 utilities 目录中创建一个空的 __init__.py 文件。完成此操作后,按照以下方式修改你的 main.py 文件:

from utilities import strings

def add(a, b):
    return a + b

print(add(12, 12))
print(strings.add(12, 12))

from utilities import strings 语句告诉 Python 导航到 utilities 包,并将 strings.py 文件中的所有对象拉入 strings 命名空间。print(strings.add(12, 12)) 行已被更改为使用 strings 命名空间来访问 add() 功能。现在,命名空间加函数名结合在一起,增加了 add 函数的清晰度和意图。

当你创建一个打算导入到程序其他部分的 Python 文件时,通常会将该文件视为一个模块。该模块包含对程序有用的功能。这个想法与经常与 Python 联系的“包含电池”的说法非常相似。Python 随带提供了一系列标准模块,你可以在程序中导入和使用。

如果你使用过 Python 的任何标准模块,比如 sys,你可能注意到那些标准模块并不像你之前创建的 strings.py 模块那样存在于你的程序的工作目录中。Python 通过一系列路径来搜索你想要导入的模块,工作目录排在第一位。

如果你启动 Python 解释器,并在提示符下输入

>>> import sys
>>> sys.path

你会看到类似以下的输出:

['', '/Users/dfarrell/.pyenv/versions/3.8.0/lib/python37.zip', '/Users/dfarrell/.pyenv/versions/3.8.0/lib/python3.8', '/Users/dfarrell/.pyenv/versions/3.8.0/lib/python3.8/lib-dynload', '/Users/dfarrell/tmp/sys_path_test/.venv/lib/python3.8/site-packages']

输出是 Python 在代码中遇到 importfrom 语句时将搜索的路径列表。这里显示的列表特定于我的 Mac;你看到的列表很可能取决于你是否在使用 Windows 或 Mac 计算机,以及你是否在虚拟环境中运行 Python。

列表中的第一个元素是一个空字符串。Python 将在当前工作目录中查找模块。这就是它找到 utilities 包以及该包中的 strings 模块的方式。

这也意味着,如果你创建了一个模块,并且将其命名为与 Python 系统模块相同的名称,Python 将首先找到你的包并使用它,而忽略系统包。在命名你的包和模块时,请记住这一点。

在我们的简短示例中,import sys 语句导致 Python 搜索前面提到的路径列表。由于你的工作目录中没有 sys 模块,它会在其他路径中查找,并在那里找到了标准模块。

当使用 pip 命令安装包或模块时,会使用路径列表。pip 命令将包安装到列表中的某个路径。如前所述,建议使用 Python 虚拟环境来防止 pip 将包安装到你的计算机系统中的 Python 版本。

2.3.3 函数级别

你还有其他级别的命名空间控制可用。当你创建一个 Python 函数时,你正在创建一个用于变量名称创建的命名空间。另一个用于这个的词是 作用域。你创建的函数存在于一个模块中,无论是你的程序的主 Python 文件还是单独的模块文件。

模块文件创建了一个命名空间,你在模块中创建的任何函数都存在于这个命名空间中。这意味着什么?请将你的 strings.py 模块进行以下更改:

prefix = "added"

def add(a, b):
    return f"{prefix}: {a} {b}"

这些更改在模块级别的命名空间中创建了一个名为 prefix 的变量,并将其初始化为字符串 "added."

如果你运行你的主程序,你会看到 strings.add(12, 12) 的输出现在为 added: 12 12。当 add() 函数执行时,Python 在函数作用域内寻找 prefix 变量,没有找到后,它会查看模块级别的命名空间。它在模块中找到了 prefix 变量,并使用它来格式化函数返回的字符串。

再次更改 strings.py 代码,使其看起来像这样:

prefix = "added"

def add(a, b):
    prefix = "inside add function"
    return f"{prefix}: {a} {b}"

add() 函数内部,你创建了一个名为 prefix 的变量并将其初始化为不同的字符串。如果你重新运行你的代码,你会看到 strings .add(12, 12) 函数的输出为 inside added: 12 12

这里发生的情况是,Python 现在在 add() 函数的局部命名空间中找到了 prefix 变量并使用了它。不仅 prefix 变量在 add() 函数的命名空间内定义,而且它也是在函数的作用域中创建的。我们将在下一节中更多地讨论作用域。

2.3.4 命名空间作用域

名称和命名空间在 Python 工具箱中至关重要,并且与前面章节中暗示的另一个工具相关。在创建和使用变量时,变量的作用域是一个重要的考虑因素。

变量的作用域与其在模块(Python 文件)中的可访问性和生命周期相关。在 Python 中,当给变量赋值时创建变量:

prefix = "prefix"

这个语句创建了变量 prefix 并将其赋值为字符串值 "prefix"。变量 prefix 也是一个字符串类型,Python 在赋值时根据被赋值的对象确定类型——在这种情况下,是 "prefix"

如果前缀变量是在函数中创建的

def print_prefix():
    prefix = "prefix"
    print(prefix)

prefix 变量将位于 print_prefix() 函数的作用域内,并且仅在函数运行时存在。任何尝试在函数外部访问 prefix 变量的代码都会引发异常。

假设你创建了一个名为 message.py 的新模块文件,其外观如下:

prefix = "prefix"

def my_message(text):
    new_message = f"{prefix}: {text}"
    return new_message

你创建了具有不同范围和生命周期的东西。prefix 变量位于全局模块范围。它在 message.py 模块内的任何地方都可以访问。它也具有与模块相同的生命周期。

在这个上下文中,生命周期 指的是从模块导入应用程序的点开始,到应用程序退出时为止。可以删除模块并重新导入它,但在实践中,这很少是必要的。

如果你使用 import message 在你的代码中,prefix 变量和 my_message 函数将存在于消息模块存在的时间内。它仍然在消息命名空间内,并且可以被像这样导入的程序访问:

import message
print(message.prefix)
print(message.my_message("Hello World"))

my_message(text) 函数内部定义的变量具有函数级范围。这意味着它们只能在函数内部访问,它们的生命周期从创建点到函数语句的结束。

因为 my_message(text) 函数包含在模块级作用域内,所以函数内的代码可以访问 prefix 模块变量。在模块作用域中,在该级别声明的声明是可访问的——prefixmy_message 函数。my_message 函数是模块级(全局)作用域的一部分,但函数内部声明的所有变量都不是。

my_message 函数内部,两个变量 textnew_message 可以像在局部函数范围内一样访问,但函数外部无法访问。模块变量 prefix 位于全局范围,并在函数内部也可以访问。

之前的程序表明作用域是嵌套的。内部作用域可以访问包围它们的内部作用域,正如 my_message 函数可以访问 prefix 变量所展示的那样。外部作用域无法访问它们所包围的内部作用域。图 2.4 展示了这种作用域嵌套。

图 2.4 作用域如何相互嵌套以及变量在作用域中的可见位置

社会命名空间

编程命名空间和作用域可以与我们生活中来自各个部分认识的人进行比较。比如说,你有一个叫玛丽的女性朋友,你也在和一个叫玛丽的同事一起工作。如果你和朋友们在一起,有人提到“玛丽”,你很可能会想到你的朋友;她处于你的局部作用域。

然而,如果有人说,“你工作的那个人怎么样;玛丽,我想她的名字是?”在这种情况下,你会想到你工作的玛丽,因为问题改变了上下文,使其变为工作模块作用域。

2.3.5 命名空间实验

使用你所学的关于名称和命名空间的知识,尝试使用提供的信息进行这个实验来解决一个问题。这个问题是关于使用有意义的名称和命名空间来解决一个本应尴尬的开发问题。

实验

你是负责维护一个对类似事物感兴趣的在线用户社区的软件的开发者。该社区希望软件能够通过电子邮件通知他们即将发生的事件,并包括那些注册接收事件电子邮件的非社区成员。后者包括对加入感兴趣但尚未承诺加入的潜在新成员。

软件可以向注册用户的邮件列表发送个性化的电子邮件,包括成员和非成员。在创建个性化电子邮件时,当前软件调用一个名为get_name(person)的函数,根据传递给它的person对象获取要渲染到电子邮件中的名称。

社区希望通过为名称创建“正式”、“非正式”和“随意”的概念来改变个性化电子邮件的渲染方式。发送给非成员的电子邮件将始终使用正式版本。发送给成员的电子邮件将基于用户的账户设置,可以使用任何三个正式程度的级别。这成为你的要求:使逻辑命名的get_name(person)函数针对三种不同的用例返回三个不同的值。

可能的解决方案

一种可能的解决方案是创建三个新的get_name(person)函数版本,如下所示:

def get_name_formal(person):

def get_name_informal(person):

def get_name_casual(person):

这段代码易于理解且直接,但在当前调用get_name(...)的应用程序中却显得有些笨拙。使用这种方法需要你修改每个get_name(...)调用的实例,将其改为if/elif/else条件语句以调用正确的函数。你还需要为这些if/elif/else条件测试提供选择正确函数的条件信息。

另一种方法是将get_name(person)函数修改为接受一个额外的参数,该参数指示如何格式化响应。如下所示,这种方法是可行的:

def get_name(person, tone: str):

在这个例子中,变量tone是一个理论上设置为formalinformalcasual的字符串。tone的值将用于以预期的格式格式化名称。

这个例子也可以工作,但只是对单独命名的函数的微小改进。选择这种方法需要你找到并编辑整个程序中调用get_name(...)函数的每个实例,并将其更新为包括新的tone参数。如果函数被用在很多地方,这可能会成为一个维护难题。

使用命名空间可以创建一个可行的解决方案,而不会干扰软件的其他部分。你不必更改get_name(person)函数的名称签名或其参数列表,可以使用命名空间。

作为一个虚构的例子,以下是一个模拟在考虑必要的更改之前向社区发送电子邮件的main.py程序:

from utilities.names import get_name

# generate a community list of three of the same people
community = [{
        "title": "Mr.",
        "fname": "John",
        "lname": "Smith"
    } for x in range(3)
]
# iterate through the community sending emails
for person in community:
    # other code that calls get_name many times
    print(get_name(person))

names.py模块中的get_name(person)函数可能如下所示:

def get_name(person):
    title = person.get("title", "")
    fname = person.get("fname", "")
    lname = person.get("lname", "")
    if title:
        name = f"{title} {fname} {lname}"
    else:
        name = f"{fname} {lname}"
    return name

此函数查看person信息,并根据该人是否有头衔值来相应地格式化姓名并返回它。get_name(person)函数是正式版本,可以直接使用。

更改要求是创建基于账户确定的语气的正式、非正式和随意问候语。你已经有了get_name(person)函数的正式版本,只需要创建非正式和随意版本。在工具包目录中创建一个名为informal.py的模块文件,代码如下:

def get_name(person):
    fname = person.get("fname", "")
    lname = person.get("lname", "")
    name = f"{fname} {lname}"
    return name

此函数将姓名的首字母和姓氏连接起来,并省略了头衔。在工具包目录中创建另一个名为casual.py的模块,其代码如下:

def get_name(person):
    fname = person.get("fname", "")
    name = f"{fname}"
    return name

此函数返回人的名字,不包含其他信息。

根据更改要求,你还需要创建一种方法来根据社区成员的账户信息定义在电子邮件中使用的语气。需要检查的信息是,他们是否是成员,如果是成员,账户中的问候设置是什么。

对于这个实验,你可以在utilities包目录中创建一个名为account.py的模块。account.py模块包含以下代码:

from random import choice

def get_tone(person):
    return choice(["formal", "informal", "casual"])

此代码从包含语气字符串列表("formal""informal""casual")中随机选择一个值。在实际应用中,语气可能从包含有关用户信息的数据库表中检索。

现在你已经拥有了满足要求并改变邮件列表处理方式所需的一切。以下是更新后的main.py程序列表,展示了你创建的命名空间是如何被使用的:

from utilities import names
from utilities import informal
from utilities import casual
from utilities import account

community = [{
        "title": "Mr.",
        "fname": "John",
        "lname": "Smith"
    } for x in range(3)
]

for person in community:
    tone = account.get_tone(person)
    if tone == "formal":
        get_name = names.get_name
    elif tone == "informal":
        get_name = informal.get_name
    elif tone == "casual":
        get_name = casual.get_name
    else:
        get_name = names.get_name

    # other code that calls get_name many times
    print(get_name(person))

此版本的main.py程序导入了三个新模块,informalcasualaccount。在社区迭代顶部,根据传递给account.get_tone(person)函数调用的person,检索语气。tone变量用于if/elif/else语句集中来设置get_name变量。

注意,get_name变量根据语气的值设置为特定模块中的get_name函数。代码将get_name变量设置为引用一个函数;它并没有调用该函数。现在get_name是一个函数对象,它可以在print(get_name(person))语句中作为函数使用。get _name(person)函数调用将得到正确的结果,因为它在每次迭代时都引用了在设置tone变量时所需模块的get_name(person)函数。

所有这些前期工作,包括创建模块及其内部的代码,都是为了避免丢失像get_name函数这样的良好逻辑名称,并允许它在程序的其他地方不变地使用。这项工作还通过使用命名空间来防止名称冲突。

摘要

  • 我们如何命名事物对我们思考正在开发的应用程序的方式有着重要且持久的影响。花时间思考我们选择的名称是非常值得的。

  • 命名空间是提供其他命名事物上下文的一种方式。因为命名空间是我们命名以包含和为我们的应用程序提供结构的另一个事物,所以对它们的命名同样适用相同的考虑。

  • 命名空间创建层次结构,建立父级、子级和兄弟关系,有助于构建 Python 应用程序。

  • Python 有多种创建命名空间的方法,这有助于复杂的应用程序共存,并通过使用方便的名称来简化它们,从而避免冲突。

3 API:让我们来谈谈

本章涵盖

  • 理解 API

  • 识别设计良好的 API

  • 创建良好的 API

通过口头和书面语言、手势、表情和语调与人沟通,是我们物种进步的基石之一。即使跨越不同的语言和文化,我们也可以相互沟通,可能需要更多的努力,但我们可以学会传达意义、意图、信息、目标等等。

计算机的发展也为我们与计算机以及与我们的交流创造了多种多样的方式。键盘、鼠标、触摸板、语音、屏幕、打印机、网络、运动传感器等等,都是用于在人与计算机之间提供不同用途的设备。

所有这些设备都是设计用来在计算机系统之间传递信息和接收信息的接口的例子。键盘为我们提供了将书面文字输入系统的机械方式。计算机鼠标提供了一种向计算机系统指示手势和事件的方式。显示屏为计算机提供了一种表示数字信息的方式,以便我们接收它。扬声器为计算机提供了一个产生音频信息的接口。

所有捕捉按键、在计算机显示上定位鼠标指针或从计算机生成声音的复杂性都被简化、隐藏并由接口提供。

接口用于接收信息、对其采取行动,并返回结果。我们之前讨论过的接口对于计算机用户来说是必不可少的。作为开发者,我们也使用存在于计算机系统更深层次和更抽象层面的接口。

计算机的操作系统提供了数百甚至数千个接口,这些接口提供了计算机能够提供的一切服务和功能。通过接口,应用程序可以访问文件系统,最终是存储系统。如果计算机连接到网络,应用程序使用接口来访问这些网络。如果应用程序渲染视觉信息,接口将这些信息呈现到连接的显示器上。这些类型的接口属于更大的通用类别,称为应用程序编程接口,或 API。

3.1 开始对话

计算机屏幕在用户看到的事物和计算机表示事物之间提供了一个抽象层次。鼠标在手的移动和按钮点击与计算机之间提供了一个抽象层次,这些动作被翻译为选择和事件。

API 提供了相同类型的抽象。然而,它并不是在人与计算机之间,而是在编程代码的不同部分之间进行。所有程序都是由自定义代码、模块和现有代码库组成的。即使是执行print("Hello World")的最简单的 Python 程序,也在使用 Python 语言提供的标准库代码。

拥有库代码是一个巨大的优势,它让你能够专注于你试图完成的事情,而不是自己编写所有代码。想象一下,每次开始一个新项目时都必须编写一个print函数,或者必须创建像网络访问这样相当复杂的东西。

Python 因其“内置电池”而闻名,这意味着它附带了一个广泛且强大的标准库模块,提供了你可以使用而无需自己创建的所有种类的功能。还有大量你可以从 Python 包索引(pypi.org/)安装的模块,涵盖了各种多样且得到良好支持的兴趣领域。

小贴士:使用现有的库代码和模块是成为一名扎实开发者的基石。现有的代码通常经过良好的测试,并在许多应用程序中成功使用。当有现成的、完美的轮子可用时,没有必要重新发明轮子。

由于这些众多的模块,Python 有时被称为“胶水语言”,因为它创造了将强大的代码库连接起来的有趣方式。将 Python 视为胶水语言并不会削弱其力量,反而显示了其多功能性。

Python 作为胶水语言之所以可能,是因为模块支持的 API,例如调用print("Hello World")。这调用print函数,传递字面字符串参数"Hello World",抽象化了将文本输出到显示器的复杂性。模块支持的 API 使得在程序中使用复杂、高级的代码成为可能。

3.1.1 代码片段之间的合同

除了关于 API 是什么的相对抽象的讨论之外,让我们回顾一下它在实际中的样子。一种思考方式是将 API 视为你自己的代码和另一段你想要使用的功能的代码之间的合同。就像人与人之间的合同一样,它规定了如果一方这样做,另一方就会那样做。

在编程术语中,这通常意味着以特定方式调用函数或方法时,它会执行一些工作并返回一些信息,或者两者兼而有之。在 Python 中,当你创建一个在其他代码部分使用的函数时,你就创建了一个 API。你给函数取的名字表达了 API 所做的一些含义。函数的输入参数将信息传递给 API,以指定要执行的工作以及执行它的数据。如果函数返回信息,这就是 API 的输出。

这种将信息传递到一段代码中并从中获取信息或执行动作的想法在计算机科学中已经存在很长时间,被称为“黑盒”模型。黑盒期望的输入和它创建的输出已经足够了解,以至于了解内部发生的事情不是必要的。只需要了解行为,而不是实现。术语黑盒来自这样的想法,即被调用的功能内部是透明的,并且阻止了视图,如图 3.1 所示。

图 3.1 功能隐藏的黑盒概念表示

作为开发者,您不必了解print()函数的内部实现。您只需要知道传递字符串到函数会调用其默认行为——将该字符串打印到屏幕上。

本地 API 是您创建的函数或类,或者您代码导入的模块。API 位于您的程序代码上下文中,并且可以通过调用提供的函数和类实例方法直接访问。

还可以调用远程托管的服务器上的 API——例如,连接到数据库服务器并访问数据。在这里,API 通过网络连接访问,为您的代码调用远程 API 以及 API 响应提供传输机制。我们将在第十章中更深入地探讨这一点,特别是关于数据库的内容。

3.1.2 输入传递的内容

当您的代码调用 API 的函数或方法时,它正在参与您程序与 API 提供的功能之间的合同的一部分。输入参数是从您的程序代码上下文通过参数传递到 API 上下文的信息。在开发者的术语中,参数是传递给函数的值,而参数是在定义函数时给这些参数赋予的名称。

Python 函数和方法支持位置参数和关键字参数。在使用和构建 API 时,位置参数的顺序和关键字参数的名称是需要考虑的因素。例如,Python 的print函数通常是这样使用的:

>>> msg = "Hello World"
>>> print(msg)
Hello World

当这个函数执行时,它将字符串变量msg打印到屏幕上。print函数提供的 API 足够简单,易于理解;它接受输入参数并执行必要的操作以将其输出到屏幕。

print函数的完整 API 显示它是一个更通用的函数。以下是print函数的签名:

print(*objects, sep=' ', end='\n', file=sys.stdout, flush=False)

此签名表示第一个参数是一个非关键字参数的元组,后面跟着具有默认值的额外关键字参数。*objects参数允许调用者向print函数传递多个以逗号分隔的值。这意味着

>>> print("Hello World")
Hello World

输出与

>>> print("Hello", "World")
Hello World

以这种方式调用 print 函数是可行的,因为该函数会遍历 objects 元组参数,如果需要,将每个对象转换为字符串,并将每个对象输出到 sys.stdout(屏幕)。每个输出项之间用默认的分隔符字符串,一个空格字符分隔。

sep='' 参数提供了默认的空格分隔符字符串,并允许您将其更改为其他内容,以便在将对象输出到屏幕时分隔它们。end='\n' 参数提供了默认的回车符以结束输出,并允许您更改输出的结束方式。

file=sys.stdout 参数定义了默认的目标位置,称为标准输出,通常是指屏幕。更改此参数可以让您更改该目标。您设置的文件参数必须具有 write(string) 方法才能作为文件目标工作。如果对象没有 write(string) 方法,当调用 print 函数时将引发 AttributeError 异常。flush=False 参数提供了一种强制将发送到流中的内容推送到输出目标而不是缓冲它的方法,如果设置为 True

所有这些都告诉我们 print 函数的 API 设计得很好,而且出人意料地强大。使用初始的非关键字 *objects 元组,然后是具有默认值的键控参数,可以让您使用 print 函数最常用的场景。如果需要,其余的功能都在那里,否则可以忽略。

假设 print 函数的处理方式不同。一个简单的 API 实现可能会移除所有具有默认值的键控参数,看起来可能像这样:

print(object)

这样的 print 函数可以满足常见的使用场景,但超出这个范围就会开始出现问题。假设在这个版本投入使用不久后,您需要一次打印多个对象。扩展简单实现的一种方法是为 print 函数创建额外的变体:

print_two(object, object)
print_three(object, object, object)

如果对这种原始的 API 扩展添加额外的要求,使其使用不同的分隔符字符,比如管道字符(|),会怎样呢?遵循既定的简单变化模式会导致类似以下的结果:

print(object)
print_pipe_sep(object)
print_two(object, object)
print_two_pipe_sep(object, object)
print_three(object, object, object)
print_three_pipe_sep(object, object, object)

这种解决方案的可扩展性不好,使用此代码的人必须更改代码以使用 API 的不同排列。本例的目标不是展示 API 开发不良路径的进展,而是更密切地关注使 Python 的默认 print 函数成为良好 API 的细节。在 print 函数内部正在进行一些工作,以支持函数签名及其应用的使用场景。

这是良好 API 的一个标志。它提供了可以扩展的有用功能,而无需暴露实现这些功能的工作。作为一个开发者,您不必过多担心 print 函数的工作方式。您可以使用它,知道其功能定义良好且封装良好。

提示:设计一个好的 API 需要付出努力,尤其是如果其他开发者将使用它。API 想要执行的所有操作都应该是接口的一部分。如果开发者必须使用 API 的某些内部部分来实现目标,那么该 API 就变成了一个问题。对 API 内部功能的任何更改都有可能破坏依赖于所使用内部部分的代码。

在开发 API 时,您如何定义输入可以极大地影响其效用和未来的使用。

3.1.3 预期的输出

API 提供的合同中的另一部分是其输出。函数的输出包括三个部分:

  • 返回值

  • 对系统的操作,有时被认为是副作用

  • 异常

返回值

函数最常见的输出结果是返回值——例如,以下代码:

>>> abs(-10)
10

代码看起来像是一个数学表达式,这非常接近它所模仿的。abs函数的输入是一个数字,输出是该数字的绝对值。

大量的编程工作涉及创建和使用接受参数的函数,处理这些参数,并返回结果。构建应用程序是一个协调函数调用并将返回值输入到其他函数中的过程,直到达到预期的结果。

因为 Python 中的所有内容都是一个对象,所以函数的返回值也是一个对象。这意味着您可以在 API 中构建一个函数,该函数返回的不仅仅是单个标量值,就像abs函数一样。

在 Python 中常见的一个例子是返回一个元组。返回一个元组允许您将多个值传递回调用函数,然后可以解包元组到变量中。以下是从examples/CH_03/example_01.py中的代码:

from typing import Tuple

def split_fullname(full_name: str) -> Tuple[str, str, str]:
    fname = mname = lname = ""
    parts = full_name.split()
    if len(parts) >= 1:
        fname = parts[0]
    if len(parts) >= 2:
        mname = parts[1]
    if len(parts) == 3:
        lname = parts[2]
    if not lname:
        mname, lname = (lname, mname)
    return (fname, mname, lname)

# use case
fname, mname, lname = split_fullname("John James Smith")

split_fullname()函数接受一个全名并返回姓名部分,fnamemnamelname。即使full_name参数只包含姓名的一个或两个部分,该函数也能正确运行。如果有两个参数,它假定第二个是姓氏,并将mname设置为空字符串。

用例展示了函数返回的元组如何解包到三个变量中。您也可以将split_fullname()的返回值赋给一个单一的元组变量,但通常将返回的元组直接解包到等待的命名变量中更有用。

对系统的操作

许多 API 函数执行工作以转换传递给它们的数据,创建新数据,或根据传递的数据执行计算。这种新数据或转换后的数据被返回给 API 的调用者以进行进一步处理。

API 函数也可以对其运行的系统执行操作。例如,如果您正在使用一个机器人 API,并调用一个函数来旋转连接到该机器人的电机,您会期望电机开始旋转。

API 函数采取的操作使得应用程序变得有用。能够打开、创建、读取和写入文件;与网络交互;打印文档;以及控制现实世界设备都是应用程序可以使用 API 功能执行的操作。

执行操作的 API 函数不一定必须向调用者返回任何数据,如果其主要目的是执行该操作。这并不意味着它不能返回输出数据。例如,用于机器人电机示例的 API 函数可以返回 TrueFalse 值,以指示电机是否正在旋转。

异常

异常及其处理是作为开发者生活的一部分。磁盘驱动器会失败,网络可能不可靠,以及可能发生任何数量的其他意外行为。

你创建的 API 功能可以从操作中生成异常,例如除以零或引发异常,因为功能创建了一个意外或异常的状态。在创建 API 函数时,你的目标之一是在可能的情况下防止异常,在无法处理时优雅地处理它们。例如,如果你的功能正在执行网络 I/O,而网络变得不可靠,你能做什么?

一种可能性是尝试操作多次,并在重试之间逐渐增加超时时间。如果在重试尝试间隔内网络稳定,则函数可以继续并成功。然而,如果重试失败,则会引发网络异常并将其传递给调用者。另一方面,如果由于输入参数导致除以零异常,你除了让异常向上冒泡到可以处理它的更高层功能之外,别无他法。

处理异常涉及了解你是否可以对此采取行动。在没有特定原因的情况下,永远不要静默异常;这样做会丢弃信息,并使 API 不可信。

你的 API 用户需要意识到并准备好处理你的 API 生成的异常,就像他们在开发过程中处理任何其他异常情况一样。记录你的 API 是一种很好的方式,可以通知用户他们可能期望的异常。异常在第五章中进行了更详细的介绍。

3.2 函数 API

函数提供了与 API 交互的机制。在面向对象编程(OOP)中,当你考虑对象上的方法时,它们是与该对象实例相关联的函数。让我们花些时间讨论你可以实施的想法来创建有用的函数,以及通过扩展,创建好的 API。

3.2.1 命名

正如我们在上一章中讨论的那样,在开发中名称很重要。你创建的函数的命名对于理解你的 API 至关重要。

函数名应使用有意义的单词,并使用 snake_case 格式。没有必要用缩写来缩短名称。每个现代代码编辑器都有自动完成功能,这使得在函数定义时只需一次性输入完整名称。

使用特定领域的缩写词也是不推荐的,因为不熟悉 API 领域的用户会发现命名约定令人困惑。例如,大多数人会认为变量名 url 表示包含网站 URL 的字符串。变量名 agtc,这是基因组研究中使用的缩写词,对许多人来说意义不大。

名称表明或暗示了函数的使用场景、它返回的内容以及它接受的内容。此外,文档字符串(docstring)可以进一步阐述预期的用途。在 Python 中,函数文档字符串是一个包含有关函数信息的三个引号字符串,它紧随函数定义之后。当逻辑名称选择相似或相同时,可能存在函数名称冲突,这时可以使用命名空间并将功能分离到模块中。

3.2.2 参数

当创建一个函数以提供封装某些功能的 API 时,你可以考虑之前提到的 Python print 函数。这个看似简单的函数因为接口定义和封装代码的构建方式,提供了令人惊讶的功能。你可以通过四种方式向创建的函数传递参数。

位置参数

这些是与函数一起使用的最常见参数形式,它们有助于定义可用性。以下是一个使用位置参数的函数定义示例:

def full_name(fname, mname, lname):
    return f"{fname} {mname} {lname}"

函数的名称表明了它返回的内容,位置参数 fnamemnamelname 阐明了预期的输入以及这些参数的期望顺序。使用字符串字面量调用函数看起来是这样的:

print(full_name("John", "James", "Smith"))

代码将字符串字面量参数按与函数定义时相同的顺序分配给位置参数。可以使用参数名以这种方式调用函数:

print(full_name(fname="John", mname="James", lname="Smith"))

也可以通过调用函数并使用关键字参数来改变参数的顺序:

print(full_name(mname="James", lname="Smith", fname="John"))

位置参数是必需的,在调用函数时必须为它们分配一个值。否则,Python 会引发一个 TypeError 异常。

关键字参数

当调用函数时,关键字参数不是必需的,因为它们有默认值。通常这些用于可选参数,允许函数在调用者不提供参数时使用已知的默认参数值运行。之前定义的 full _name 函数可以修改为使用关键字参数,如下所示:

full_name(fname, mname=None, lname=None)

现在函数有一个位置必需参数 fname 和两个关键字参数 mnamelname——每个都有默认值 None。函数将 fname 作为唯一的必需参数,这意味着如果调用者没有提供 mnamelname,函数也能正确运行。还可以以不同于函数定义的顺序使用关键字参数——例如,以这种方式调用函数:

full_name("John", lname="Smith")

这段代码表明,该函数处理了 fnamelname 被提供但 mname 被分配默认值 None 的情况。在定义函数时,一旦创建了一个具有默认值(关键字参数)的参数,任何随后的参数也必须是关键字参数并具有默认值。

参数列表

在 Python 的 print 函数中,第一个参数的形式是 *objects*objects 参数是向函数传递可变数量位置参数的一个例子。* 字符表示该参数期望可变数量的参数。objects 部分是参数的名称。

print 函数内部,objects 参数是一个包含函数中所有剩余位置参数的元组。一个可变数量的位置参数通常命名为 *args,但这只是一个约定,不是必需的。

full_name() 函数修改为使用参数列表看起来是这样的:

def full_name(fname, *names):
    return " ".join([fname, *names])

在这种形式中,full_name() 函数创建了一个包含 fname 参数和 names 元素的临时列表,以便将它们连接起来,用空格字符分隔。这种形式在传递多个相似参数时很有用,但可能会让函数的用户感到困惑。该函数将连接 *names 参数元组中的任何数量的元素,这可能不是你的意图。

在这种情况下,以所有参数都有名称的原始形式定义函数是更好的选择。从 Python 的禅意来看,明确优于隐晦。

提示:您可以通过打开 Python REPL 并输入以下命令来查看 Python 的禅意:

import this

这样做会打印出关于 Python 编程的有用建议和惯用语。

关键字参数字典

关键字参数字典类似于参数列表;它是一种将所有关键字参数包装成一个函数参数的方法。你通常会看到它被定义为 **kwargs,但同样,这只是一个约定。将 full_name() 函数修改为使用这种形式看起来是这样的:

def full_name(**kwargs):
    [CA]return f"{kwargs.get('fname', '')} {kwargs.get('mname', '')}
{kwargs('lname', '')}"

在内部,full_name() 函数检查 kwargs 字典以查找关键字 fnamemnamelname。如果没有文档,使用此函数的用户将不知道在 kwargs 字典参数中包含哪些键值对。

调用者还可以向 kwargs 字典中添加其他键值对,这些键值对可能对 full_name() 函数没有意义。任何额外的键值对都会被 full_name() 函数忽略,但可能对通过传递 kwargs 参数调用的函数有意义。使用这种形式时要小心,并且要故意这样做。

参数通常

创建适当函数签名的能力包括了解模式和一致性。许多 API 由多个函数组成,共同完成某项任务。这通常意味着将相同的数据传递给多个函数,因此每个函数都了解工作数据的状态。

如果你正在处理传递给多个函数的字典中的数据,对于所有(或尽可能多的)使用该数据的函数,使参数位置和表示公共数据的名称相同是个好主意。

例如,使字典成为函数的第一个参数,所有额外的参数传递有关如何处理字典的信息。第一个参数字典是传递给在状态数据上操作的函数之间的公共、状态数据结构:

email_user(user_data, content, from)
populate_user_address(user_data, address, city, state, zipcode)

email_user 函数从 user_data 结构中获取电子邮件地址,然后使用 contentfrom 参数生成电子邮件并发送电子邮件。popuplate_user_address 函数将地址信息添加到现有的 user_data 结构中。

同样的规则也适用于传递给函数的系统资源,文件句柄、数据库连接和数据库游标。如果多个函数需要这些资源对象,当函数具有一致的签名时,这有助于使 API 更易于理解。

具有多个参数的函数开始考验我们的认知能力,通常表明该函数做得太多,应该重构为更小的函数,每个函数只有一个目的。使用 Python 的关键字参数能力将一个长列表的函数参数合并成一个很有诱惑力。除非传递给 **kwargs 的字典有文档说明,否则它只会模糊函数期望的内容。它也回避了可能需要重构函数的原始问题。

3.2.3 返回值

正如你所见,API 合同的一半是函数返回的内容。在 Python 中,即使你的函数代码中没有返回语句,也会自动返回一个 None 值。如果你创建的函数执行系统操作(文件 I/O、网络活动、系统级更改),你可以返回 TrueFalse 来指示函数的成功或失败。

3.2.4 单一职责

努力创建只做一件事的函数;这是单一职责原则。编写一个有用的函数本身就是一项相当大的工作,尤其是如果你的目标是使函数通过深思熟虑的输入参数和处理代码变得灵活。试图让它做两件事可能会使难度加倍。

下面是一个来自examples/CH_03/example_02.py的虚构示例函数,它说明了这个概念:

def full_name_and_print(fname:str , mname: str, lname: str) -> None:
    """Concatenates the names together and prints them

    Arguments:
        fname {str} -- first name
        mname {str} -- middle name
        lname {str} -- last name
    """
    full_name = " ".join(name for name in [fname, mname, lname] if name)
    print(full_name)

这个函数将参数连接起来创建full_name变量,并将其打印到sys.stdout" ".join中的列表推导是为了确保在调用函数时mname被省略时,名称之间只有一个空格。这个函数并不像它本可以做到的那样有用,因为它试图做太多的事情。

由于没有返回完整的名称,所以对需要完整名称的其他函数来说没有用。即使完整的名称被返回,任何调用此函数的函数都必须期望完整的名称会被打印出来。

此外,这个函数难以测试,因为它不返回任何内容。为了测试这个,你必须以某种方式重定向sys.stdout,以便测试可以看到输出,这可能会很快变得混乱。

下面是来自examples/CH_03/example_02.py的一个更好的版本:

def full_name(fnameNone, mname=None, lname=None) -> str:
    """Concatenates the names together and returns the full name

    Arguments:
        fname {str} -- first name
        mname {str} -- middle name
        lname {str} -- last name

    Returns:
        str -- the full name with only a single space between names
    """
    full_name = " ".join(name for name in [fname, mname, lname] if name)
    return full_name

这个版本只做了一件事:创建完整的名称并将其返回给调用者。现在函数的返回值可以与print函数一起使用,包含在网页中,添加到数据结构中,转换为 JSON 文档,等等。这个函数也因为可以测试返回值,且相同的输入参数总是产生相同的输出而变得易于测试。

3.2.5 函数长度

与单一职责原则相关的是你编写的函数长度。一次在脑海中同时保持太多上下文和细节是很困难的。函数越长,推理和理解其行为就越困难。

关于函数长度的规则并不固定。一个很好的经验法则是大约 25 行,但这完全取决于你的舒适度。

如果你创建了一个难以理解的函数,这可能意味着该函数试图做太多的事情。解决方案是重构它,并将一些功能拆分到其他函数中。

当重构一个函数导致多个函数协同处理相同的数据时,你也可以创建一个类,将函数作为该类的成员方法。如果你遵循良好的命名习惯,并确保新函数只处理一个任务,你将创建更易于阅读的代码。

3.2.6 幂等性

虽然听起来很可怕,但在开发者术语中,“幂等”表示一个函数在给定相同的输入参数值时总是返回相同的结果。无论它被调用多少次,相同的输入总是产生相同的输出。

函数的输出不依赖于外部变量、事件或 IO 活动。例如,创建一个使用参数和时钟时间来创建返回值的函数不会是幂等的。返回值取决于函数被调用的时机。幂等函数更容易测试,因为其行为是可预测的,可以在测试代码中考虑。

3.2.7 副作用

函数可以创建副作用,这些副作用会改变函数本身作用域之外的事物。它们可以修改全局变量,将数据打印到屏幕上,通过网络发送信息,以及执行一系列其他活动。

在你的函数中,副作用应该是故意的——之前被称为对系统的操作。需要避免意外的副作用。修改全局变量是函数可以执行的操作,但应该仔细考虑,因为其他可能令人惊讶的功能可能会受到这些全局修改的影响。

当打开一个文件时,完成操作后关闭文件是一个良好的实践,以避免文件损坏的可能性。数据库连接在未使用时应关闭,以便系统的其他部分可以访问它们。一般来说,当你的函数或应用程序完成使用系统资源后,清理和释放系统资源是良好的编程实践。

在使用 Python 时,还有另一个需要注意的副作用。由于函数的参数是通过引用传递的,函数有可能改变函数作用域之外的变量。示例程序 examples/CH_03/example_03.py 展示了这一点:

from copy import copy

def total(values: list, new_value: int) -> int:
    values.append(new_value)
    return sum(values)

def better_total(values: list, new_value: int) -> int:
    temp_list = copy(values)
    temp_list.append(new_value)
    return sum(temp_list)

values_1 = [1, 2, 3]
total_1 = total(values_1, 4)
print(f"values_1 has been modified: {values_1}")
print(f"total_1 is as expected: {total_1}")
print()
values_2 = [1, 2, 3]
total_2 = better_total(values_2, 4)
print(f"values_2 unchanged: {values_2}")
print(f"total_2 is as expected: {total_2}")

当这个程序运行时,将产生以下输出:

values_1 has been modified: [1, 2, 3, 4]
total_1 is as expected: 10

values_2 unchanged: [1, 2, 3]
total_2 is as expected: 10

total()better_total() 函数都返回相同的值,10,但只有 better_total() 是幂等的。total() 函数中的代码正在修改它作为参数传入的 values_1 列表,并且存在于其作用域之外。

这是因为求和的计算方式。total() 函数直接将 new_value 追加到它所传入的 values 列表参数中。由于参数是通过引用传递的,函数外部的列表变量 values_1 和函数内部的参数变量 values 都引用了同一个列表。当 new_value 被追加到列表中时,它修改的是 values_1 所引用的同一个列表。

better_total() 函数创建 values 参数的一个副本,创建了一个独立于 values_2 列表所引用的新列表变量 temp_list。然后它将 new_value 追加到 temp_list 中,并返回 temp_list 的总和。这保留了 values_2 列表变量不变,这是预期的行为。better_total() 函数是幂等的,因为它对于给定的输入集返回相同的结果,并且没有副作用。

3.3 文档

在构建和定义 API 时,文档是一个必要的流程。函数以及它们所属的模块应该有简要描述模块功能和包含的函数功能的文档。模块应该在文件顶部包含文档字符串,描述模块提供的功能以及可能抛出的异常。

函数和类方法应该有文档字符串,简要描述函数的功能、参数用途以及预期的返回值。前面展示的示例程序中的函数包含文档字符串。以下是一个没有文档的函数示例:

def get_uuid():
    return uuid4().hex

下面是这个函数带有文档字符串的版本:

def get_uuid():
    """Generate a shortened UUID4 value to use
    as the primary key for database records

    Returns:
        string: A shortened (no '-' characters) UUID4 value
    """
    return uuid4().hex

没有文档字符串的版本的功能可能很清楚,但没有说明函数存在的原因或为什么代码会调用它。带有文档字符串的版本回答了这些问题。

您可能会听到有人说记录 Python 代码是不必要的,因为代码本身可读性很高,因此是自文档化的。可读性部分是真实的,但任何合理复杂的代码片段都能从文档中受益,帮助读者理解意图。

记录代码确实需要付出努力;然而,现代代码编辑器如 VS Code 使得插入文档字符串模板变得更加容易。示例程序中文档字符串的模板是通过在函数定义的末尾按回车键,输入三个双引号("""), 然后再按回车键生成的。

小贴士:本书中的大部分示例代码没有包含文档字符串。我的意图并不是忽略自己的建议,而是减少这些页面上的代码量,节省书籍空间。附带的代码仓库中的代码确实包含文档字符串。

许多工具将 Python 文档字符串提取和处理作为外部文档系统的一部分。除了对代码的读者(包括原始作者)有益之外,还有另一个优势。如果您在 Python 提示符下输入help(<函数名>),内置的帮助系统会显示函数的文档字符串以供参考。这包括内置函数以及您创建的函数。文档字符串的存在使得这一点成为可能。

3.4 总结

创建一个良好的 API 非常重要,因为易用性很重要。使用您 API 的用户,包括您自己,希望利用提供的功能,而不是挣扎于如何让这些功能工作。

创建有用的 API 具有挑战性;有很多移动部件和考虑因素。具有一致、逻辑参数和文档的命名模块、函数和类有助于使 API 具有良好的可用性或可发现性。就像许多事情一样,今天投入的劳动会在您使用这个受信任的工具时带来回报。

摘要

  • API 是一组结构化的接口接触点集合,应用程序可以使用它来影响 API 提供访问的代码。API 围绕操作系统代码、本地或远程运行的服务器代码,以及您构建以在应用程序中创建功能的功能模块存在。

  • API 是应用程序软件定义的合约,这样其他应用程序软件就可以一致和可预测地与之接口。

  • 创建一个好的 API 的一部分是抽象实现,这样 API 的调用者就可以依赖于接口合约,而不必使用内部实现来实现他们的目标。

  • Python 有良好的支持和工具来创建对您自己的应用程序有用的 API,并且可以发布供他人使用。

4 对话的对象

本章涵盖

  • 面向对象的 API

  • 具有类别的对象

  • 继承

  • 多态

  • 组合

在进行对话时,尤其是复杂对话,如果对话中的每个人都拥有相同的上下文,那就很有帮助。如果每次有人开始新句子时都必须呈现整个对话的上下文,那么进行对话将会很困难。

从软件函数的角度来看,上下文是函数正在处理的信息的当前状态。在前一章中,我们讨论了创建函数签名,其中数据状态以一致的方式传递给函数调用。

利用函数签名是进行在处理状态数据的功能函数之间进行交流的有用且强大的方式。如果相同的函数被传递多个不同的状态数据上下文,这会变得稍微复杂一些。数据和操作这些数据的功能是分开的,开发者需要负责保持它们的组织和连接。Python 通过使用面向对象编程模型提供另一层抽象,以减少复杂性。

4.1 面向对象编程(OOP)

将函数放入模块的能力为 API 的结构化提供了许多机会。传递给组成 API 的函数的参数的类型和顺序为使你的 API 更具可发现性和实用性提供了可能性。

使用单一责任和保持函数长度可管理的概念,使你的 API 更有可能由多个函数组成。API 功能的使用者——这些使用者可能本身会调用其他 API 函数,进一步修改数据或状态——产生返回给用户的最终结果。

通常,在函数之间传递的数据结构是集合对象,如列表、集合和字典。这些对象功能强大,在 Python 开发中利用它们提供的是很重要的。单独来看,数据结构并不做任何事情,但传递给它们的函数知道如何处理它们接收到的数据结构作为输入。

因为 Python 中的一切都是对象,你可以使用面向对象编程创建有趣的对象。创建对象的一个目标是将数据和作用于这些数据的方法封装到一个实体中。从概念上讲,你正在使用你设计和实现的功能制作某物。你可以将你创建的内容视为具有行为的对象或事物。创建类是设计这些对象的方式,将数据和功能连接到它们。

4.1.1 类定义

Python 通过定义可以在需要时实例化为实际对象的类来提供面向对象编程。实例化是将定义(类)从概念变为现实的行为。你可以把房子的蓝图看作是类定义,而建造房子则是实例化它。

这里是一个来自examples/CH_04/example_01应用程序代码的Person类的简单类定义:

class Person:
    def __init__(self, fname: str, mname: str = None, lname: str = None):
        self.fname = fname
        self.mname = mname
        self.lname = lname

    def full_name(self) -> str:
        full_name = self.fname
        if self.mname is not None:
            full_name = f"{full_name} {self.mname}"
        if self.lname is not None:
            full_name = f"{full_name} {self.lname}"
        return full_name

这个类定义创建了一个包含一个人的姓、名和姓的Person模板。它还提供了一个full_name()方法,根据通过其初始化__init__()方法传递给对象的信息来获取人的全名。与类相关联的函数通常被称为方法。这是一个约定,用于区分模块函数和类的一部分。创建和使用从Person类实例化的对象看起来是这样的:

>>> p1 = Person("George", "James", "Smith")
print(p1.full_name())

作为Person类每个方法的第一参数传递的self参数是刚刚创建的Person实例的引用。这样,你的代码可以创建所需数量的Person实例,并且每个实例都是独特的,因为每个self值将引用特定的实例及其包含的状态属性(数据)。

这个类也可以用 UML(统一建模语言)来可视化表示,如图 4.1 所示。UML 是一种以视觉方式展示系统设计的方法。它是

在设计和构建系统时,不一定需要使用 UML 图,但它可以有助于介绍那些仅用文本文档难以简洁表达的概念。

图 4.1 Person类的 UML 图

Person类的 UML 图显示了类的名称、它包含的属性以及它提供的方法。属性和方法名称前的加号字符(+)表示它们是公共的。在 Python 中,类的属性和方法始终是公共的,没有受保护或私有访问的概念。

Python 的类设计依赖于“我们都是成年人”的想法,使用你的类进行开发的开发者将相应地行事。在设计类时,使用普通属性应该是默认的。你将在后面看到类属性如何获得控制属性访问和使用的方式。Person类的一个简单用例在examples/CH_04/example_01应用程序中给出:

def main():
    people = [
        Person("John", "George", "Smith"),
        Person("Bill", lname="Thompson"),
        Person("Sam", mname="Watson"),
        Person("Tom"),
    ]

    # Print out the full names of the people
    for person in people:
        print(person.full_name())

这段代码创建了四个Person类的实例,每个实例代表不同的人,并使用了构造函数的所有变体。for循环遍历Person对象实例的列表,并调用每个实例的full_name()方法。注意,full_name()方法没有传递任何状态数据;它使用与类实例相关联的数据属性。full_name()方法定义中的self参数是使方法能够访问个体属性的原因。

4.1.2 使用类绘图

你将要构建的剩余示例都是面向对象的应用程序,它们在屏幕上动画化一些形状。

提示:有面向对象编程经验的读者可能会认识到这个类比——一个通用形状,从这个形状中可以继承出特定形状,如矩形和正方形。这个类比已经用于展示面向对象技术很长时间了,并且已经变得有些牵强。我承认这一点,但仍然在使用它,因为它有优势。

形状的概念在编程之外已经足够熟悉,读者可以与之以及从它们派生出新形状的想法联系起来。此外,在计算机屏幕上移动形状的程序对大多数读者来说也很熟悉。移动形状具有速度和方向,并保持在屏幕窗口边界内的想法是计算机渲染图形的众所周知的行为。

由于对形状的熟悉,学习面向对象程序的认知需求可以集中在这一点上,而不是对象的任何抽象属性上。因此,我要求你忍受示例的虚构性质,以看到更大的图景。以下每个示例都是在前一个示例的基础上扩展的,以展示以下概念:

  • 继承—类之间的父/子关系

  • 多态—将对象用作具有多种形式

  • 组合—通过除继承之外的方式给类赋予属性和行为

要创建绘图应用程序,你将使用 Python 包索引中可用的 arcade 模块(pypi.org/project/arcade/)。此模块提供了在计算机屏幕上构建绘图表面并在此绘图表面上绘制和动画对象的框架。

首件事是定义一个用于在屏幕上绘制的矩形的类。图 4.2 中的 UML 图显示了类中封装的属性,这些属性是绘制屏幕上的矩形所必需的;xywidthheight定义了矩形在屏幕上的位置以及绘制时使用的尺寸。

图 4.2 Rectangle类的 UML 图

所有这些属性都是在Rectangle对象实例化期间初始化的:

  • pen_color, fill_color—定义了用于勾勒矩形和填充它的颜色

  • dir_x, dir_y—定义了相对于屏幕 x 和 y 轴的运动方向;这些值可以是 1 或-1

  • speed_x, speed_y—定义了矩形在每次更新中移动的速度(以像素为单位)

图 4.2 还包括了类支持的三种方法的定义:

  • set_pen_color()—提供了一种机制来设置用于绘制Rectangle实例对象的笔颜色

  • set_fill_color()—提供了一种机制来设置用于填充Rectangle实例对象的填充颜色

  • draw()—在屏幕上绘制Rectangle对象实例

此 UML 图被转换为代码中的 Python 类定义。以下是基于之前 examples/CH_04/example_02 应用程序中图示的 Rectangle 类:

class Rectangle:
    def __init__(
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        pen_color: tuple = COLOR_PALETTE[0],
        fill_color: tuple = COLOR_PALETTE[1],
        dir_x: int = 1,
        dir_y: int = 1,
        speed_x: int = 1,
        speed_y: int = 1
    ):
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.pen_color = pen_color
        self.fill_color = fill_color
        self.dir_x = 1 if dir_x > 0 else -1
        self.dir_y = 1 if dir_y > 0 else -1
        self.speed_x = speed_x
        self.speed_y = speed_y

    def set_pen_color(self, color: tuple) -> Rectangle:
        self.pen_color = color
        return self

    def set_fill_color(self, color: tuple) -> Rectangle:
        self.fill_color = color
        return self

    def draw(self):
        arcade.draw_xywh_rectangle_filled(
            self.x, self.y, self.width, self.height, self.fill_color
        )
        arcade.draw_xywh_rectangle_outline(
            self.x, self.y, self.width, self.height, self.pen_color, 3
        )

此类定义了一个简单的 Rectangle 对象。对象通过 x 和 y 坐标、宽度、高度、笔和填充颜色、方向以及矩形运动的速度初始化。在 arcade 模块中,屏幕原点位于左下角,这是我们大多数人认为的纸张上的 x 和 y 轴,但它与许多其他屏幕渲染工具不同。

提示:绘图屏幕左上角的 0, 0 原点位置是出于历史原因,这与当时计算机图形是如何生成的有关。

修改 xy 属性的值会移动屏幕上的 Rectangle,这是由 arcade 模块和应用程序中 Window 类的实例维护的。Window 类有两个用于在屏幕上动画对象的方法:on_update()on_draw()。第一个更新所有要在屏幕上渲染的对象的位置,第二个在屏幕上绘制这些更新后的对象。on_update() 方法在每次刷新迭代时被调用,并且是应用程序修改 self.rectangles 集合中矩形位置的地方。on_update() 方法看起来如下所示:

def on_update(self, delta_time):
    for rectangle in self.rectangles:
        rectangle.x += rectangle.speed_x
        rectangle.y += rectangle.speed_y

此代码遍历矩形集合,通过其 xy 速度值更新每个矩形的位罝,改变其在屏幕上的位置。

更新的矩形是通过窗口实例方法 on_draw() 绘制到屏幕上的,其看起来如下所示:

def on_draw(self):
    # Clear the screen and start drawing
    arcade.start_render()

    # Draw the rectangles
    for rectangle in self.rectangles:
        rectangle.draw()

每次调用 on_draw() 方法时,屏幕会清除,并遍历 self.rectangles 集合,调用每个矩形的 draw() 方法。

Rectangle 类通过 set_pen_color()set_fill_color()draw() 方法定义了行为。这些方法使用并修改由类定义封装的状态数据。它们提供了当你使用类时与之交互的 API。使用这些方法可以抽象出直接修改状态数据的需要。

观察 set_pen_color()set_fill_color() 方法,你会发现它们返回 self。返回 self 可以在类的方法链中串联一系列操作,非常有用。以下是从 examples/CH_04/example_02.py 中使用 Rectangle 类的一个示例。此代码在每秒调用 arcade 调度功能代码时更改笔和填充颜色:

def change_colors(self, interval):
    for rectangle in self.rectangles:
        rectangle.set_pen_color(choice(COLOR_PALETTE)).set_fill_color(
            choice(COLOR_PALETTE)
        )

Window 实例的 change_colors() 方法每秒由 arcade 调度函数调用。它遍历矩形集合,并以链式方式调用 set_pen_color()set_fill_color(),以设置从全局定义的 COLOR_PALETTE 列表中随机选择的颜色。

examples/CH_04/example_02 应用程序运行时,它会在屏幕上创建一个窗口,如图 4.3 所示。应用程序以 45 度角垂直向上和向右动画一个矩形。它还会在应用程序运行每秒钟更改矩形的笔和填充颜色。

图片

图 4.3 窗口绘制表面的矩形截图

属性

如前所述,直接访问类的属性通常应该是默认的。Rectangle 示例遵循了这一做法。然而,有些情况下,你可能希望对类属性的用法或更改有更多的控制。

Rectangle 类的定义包括矩形的 x 和 y 原点属性,这有助于在窗口中绘制它。那个窗口有尺寸,如果你运行 examples/CH_04/example_02 应用程序足够长的时间,你会看到矩形移出屏幕。

目前,Rectangle 实例的来源被设置为任何整数值。已知没有任何屏幕的分辨率能达到整数值的范围,而且没有任何屏幕直接处理负数。应用程序中声明的窗口宽度范围从 0 到 600 像素,高度范围从 0 到 800 像素。

Rectangle 对象可以绘制的边界应该限制在窗口尺寸内。限制 xy 的值意味着要有代码来限制可以分配给它们的值。你的目标是让矩形在屏幕窗口内弹跳。

如果你习惯了支持 OOP 的其他语言,你可能熟悉 getter 和 setter。这些是由开发者提供的方法,用于控制对类实例属性的访问。这些方法还允许开发者在属性检索或修改时插入行为。当你设置或获取 xy 值时,你想要插入的行为是限制可以设置给这些属性的值范围。

可以通过定义如下方法将 getter 和 setter 方法添加到矩形的 xy 属性中:

def get_x(self):
def set_x(self, value):
def get_y(self):
def set_y(self, value):

使用这些 getter 和 setter 函数也意味着更改示例代码从

rectangle.x += 1
rectangle.y += 1

to this:

rectangle.set_x(rectangle.get_x() + 1)
rectangle.set_y(rectangle.get_y() + 1)

在我看来,使用 getter 和 setter 是可行的,但与直接属性访问版本/语法相比,牺牲了可读性。通过使用 Python 属性装饰器,你可以在使用直接属性访问语法的同时控制类属性的访问和修改。Rectangle 类可以被修改为使用提供此行为的属性装饰器。这里展示了从示例程序 examples/CH_04/example_03 中更新的 Rectangle 类的部分内容:

class Rectangle:
    def __init__(
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        pen_color: str = "BLACK",
        fill_color: str = "BLUE",
    ):
        self._x = x
        self._y = y
        self.width = width
        self.height = height
        self.pen_color = pen_color
        self.fill_color = fill_color

    @property
    def x(self):
        return self._x

    @x.setter
    def x(self, value: int):
        if self._x + value < 0:
            self._x = 0
        elif self._x + self._width + value > Screen.max_x:
            self._x = Screen.max_x - self._width
        else:
            self._x = value

    @property
    def y(self):
        return self._y

    @y.setter
    def y(self, value):
        if self._y + value < 0:
            self._y = 0
        elif self._y + self._height + value > Screen.max_y:
            self._y = Screen.max_y - self._height
        else:
            self._y = value

第一个需要注意的元素是属性xy前面有一个单下划线(_)前缀。使用下划线的方式是一种约定,表示属性应该被视为私有,并且不应直接访问。然而,这并不强制执行任何私有属性的概念。

第二个需要注意的元素是类中的新装饰方法。例如,访问self._x属性的两个新方法如下:

    @property
    def x(self):
        return self._x

    @x.setter
    def x(self, value):
        if not (0 < value < SCREEN_WIDTH - self.width):
            self.dir_x = -self.dir_x
        self._x += abs(self._x - value) * self.dir_x

在第一个def x(self)函数上使用@property装饰器定义了 getter 功能——在这种情况下,只是返回self._x的值。

在第二个def x(self, value)函数上使用@x.setter装饰器定义了 setter 功能。在函数内部,self._x被限制在屏幕 x 轴的最小和最大维度内。如果设置self._x的值将矩形的任何部分放置在屏幕区域之外,移动方向将被否定,以开始向相反方向移动。在Rectangle类中有这些装饰方法意味着像这样的代码又可以工作了:

rectangle.x += 1

程序语句看起来是直接设置Rectangle实例的 x 属性,但实际调用的是装饰方法。+=操作调用 getter 方法来检索self._x的当前值,将 1 加到这个值上,并使用 setter 方法将self._x设置为这个新值。如果这种变化将矩形放置在屏幕尺寸之外,x 轴上的移动方向将被反转。

这部分的美妙之处在于你可以最初使用直接属性访问来定义你的类。如果需要约束或控制对属性的访问,你可以定义 getter 和 setter 属性方法。使用你的类的现有代码根本不需要更改。从调用者的角度来看,类的 API 是相同的。

注意使用 setter 和 getter 装饰方法的一个特性:你不需要在属性上创建 setter 和 getter 装饰函数。你可以只创建一个 getter,它会产生一个只读属性。同样,你也可以只创建一个 setter,它会产生一个只写属性。还有一个@deleter装饰器用于删除属性,但这个特性很少使用。

装饰器

在继续之前,让我们谈谈装饰器。在 Python 中,装饰器是一种在不改变函数本身的情况下扩展或修改函数行为的方法。装饰函数听起来可能有些令人困惑,但一个例子将有助于阐明意图。如前所述,函数在 Python 中是对象。这意味着函数可以作为参数传递给其他函数,就像任何其他对象一样。

这里定义的函数演示了装饰器的使用:

from time import sleep

def complex_task(delay):
    sleep(delay)
    return "task done"

当函数被调用时,它使用延迟参数来模拟一些需要时间才能完成的复杂任务。当函数结束时,它返回字符串task done

假设需要在函数调用前后记录执行时间的信息,包括执行所需的时间量。这可以通过将日志信息添加到函数本身来实现,但这会引发代码维护问题,因为如果计时代码发生变化,需要更新的函数将很多。你可以创建一个装饰器函数来包装 complex_task 并添加所需的新功能。装饰器函数看起来像这样:

def timing_decorator(func):
    def wrapper(delay):
        start_time = time()
        print("starting timing")
        result = func(delay)
        print(f"task elapsed time: {time() - start_time}")
        return result
    return wrapper

这段代码看起来有些奇怪,因为 timing_decorator 函数在其内部定义了另一个名为 wrapper 的函数。外部的 timing_decorator 函数也返回了 wrapper 内部函数。这完全符合 Python 语法,因为函数是对象;当外部的 timing_decorator 函数执行时,wrapper 函数被创建并返回。

examples/CH_04/example_04 程序演示了直接包装任务和使用装饰器语法,运行时产生以下输出:

wrapper 函数内部的代码将会执行,包括调用装饰的 func 对象。以下示例将有助于阐明正在发生的情况:

new_complex_task = timing_decorator(complex_task)
print(complex_task(1.5))

在这里,complex_task 函数对象被传递给 timing_decorator 函数。注意 complex_task 上没有括号;传递的是函数对象本身,而不是函数调用的结果。新变量 new_complex_task 被分配了 timing_decorator 的返回值,因为它返回的是包装函数,所以 new_complex_task 是一个函数对象。

打印语句调用 new_complex_task,并传递一个延迟值,打印以下信息:

starting timing
task elapsed time: 1.6303961277008057
task done

此输出显示了 timing_decorator 添加的功能以及 complex_task 的原始功能,该功能被执行。

这个例子很有趣,但并不那么有用,因为每次调用 complex_task 都必须作为参数传递给 timing_decorator 以获得额外的计时功能。Python 支持一种语法快捷方式,通过在 complex_task 函数定义之前添加 @timing_decorator 来简化这个过程。这个添加的效果是“装饰” complex_task 并创建一个现在已包装函数的可调用实例。代码如下所示:

@timing_decorator
def complex_task(delay):
    sleep(delay)
    return "task done"

print(complex_task(1.5))

timing_decoratorfunc 参数是要装饰的函数对象。wrapper 函数的 delay 参数是传递给装饰函数的参数。

starting timing
task elapsed time: 1.5009040832519531
task done

starting timing
task elapsed time: 1.5003101825714111
task done

输出显示了 complex_task 的运行情况,但也表明 @timing_decorator 已将 complex_task 包装了额外的功能,这些功能也在运行并生成关于经过时间的日志消息。complex_task 代码没有改变以提供这些功能;timing_decorator 内部的 wrapper 函数执行这项工作。这里的优势是,任何与 complex_task 有相同签名的函数或方法都可以用 @timing_decorator 装饰以生成计时信息。

4.1.3 继承

能够将数据及其相关行为合并到类中,为你提供了非常灵活的方式来组织你的程序。在构建类时,会出现功能在多个类中是共通的情况。作为开发者,遵循 DRY(不要重复自己)原则已经成为我们天性的一部分。在 Python 中创建对象时,你可以通过使用继承来遵循这一原则。

小贴士:作为开发者,避免重复是很有益的,因为重复会为引入错误或代码中的差异打开大门。如果你使用的重复代码是正确的,那么它在任何地方都是正确的。如果你重复代码,就重新编写它;它可能在某些地方是正确的,在其他地方是错误的。这使得查找和修复错误变得困难。

就像真正的父母和他们的孩子一样,属性和行为是从父类继承下来的,但并不是完全相同的副本。在谈论面向对象(OOP)类设计时,使用“父类”和“子类”这些术语是因为这个隐喻非常适用。同时,也使用“基类”和“派生类”来表示父类和子类之间的关系。

你还会看到像“超类”(superclass)这样的词用来指代父类,“子类”(subclass)用来指代子类。这些术语是在讨论继承时应用于对象之间关系时使用的。

使用继承的一个原因是为子类添加独特的属性和行为,可能修改从父类继承而来的那些。从父类派生多个子类也很有用,每个子类都有其独特的属性和行为,但仍然具有来自父类的特征。在 Python 中创建两个类之间的继承关系是这样进行的:

class ParentClass:
    pass

class ChildClass(ParentClass):
    pass

ParentClass的定义创建了一个根级别的类定义。ChildClass的定义包括括号内继承的类。在示例中,它从ParentClass继承。在两个类定义中的pass语句在 Python 中是一个无操作(nop),它是使类定义在语法上正确但无功能所必需的。

examples/CH_04/example_02代码中,创建了一个具有屏幕位置、用于绘制的笔颜色以及填充矩形颜色的Rectangle类。如果你想要创建其他形状,比如正方形和圆形呢?每个形状在屏幕上都有一个位置和尺寸,以及笔和填充颜色。

直接的方法是创建完整的、独立的SquareCircle类定义,并在屏幕上绘制每个类的实例。每个类都会拥有Rectangle类的所有属性和方法,但具有不同的draw()方法来绘制独特的形状。为SquareCircle创建单独的类对于涉及相对较少的形状来说是可行的,但如果需要更多的形状,则扩展性不好。

这提供了一个使用继承将属性及其相关行为聚集到可以称为 Shape 的父类中的机会。这个 Shape 父类将用于在一个地方收集公共属性和方法。屏幕上绘制的任何形状都将成为 Shape 父类的子类。

你将首先通过利用继承来重现 examples/CH_04/example_03 应用程序的功能。接下来的示例来自 examples/CH_04/example_04 应用程序。

图 4.4 显示了 Rectangle 类定义的属性和方法已移动到 Shape 类,并且 Rectangle 现在从它继承。斜体中的 Shape 名称表示它是抽象的,不应直接实例化。draw() 方法也是斜体,因为它存在于 Shape 定义中,但没有自己的功能。功能必须由子类提供——在这个例子中,是 Rectangle

因为 Shape 类本质上就是之前的 Rectangle,所以这里没有显示代码;相反,这里显示了更新的 Rectangle 类:

class Rectangle(Shape):

    def draw(self):
        arcade.draw_xywh_rectangle_filled(
            self.x, self.y, self.width, self.height, self.fill_color
        )
        arcade.draw_xywh_rectangle_outline(
            self.x, self.y, self.width, self.height, self.pen_color, 3
        )

图片

图 4.4 显示 ShapeRectangle 类之间关系的 UML 图

Rectangle 类的第一行已经被修改,包括括号内的 Shape。这就是 Rectangle 类从 Shape 类继承的方式。

矩形已经被重构,只有一个独特的 draw() 方法来在屏幕上绘制自己。draw() 方法覆盖了由 Shape 类提供的空抽象方法。其他一切均由 Shape 类管理和维护。甚至 __init__() 初始化器也被移除了,因为 Shape 类的初始化器已经足够。

询问将原始 Rectangle 类拆分为两个新类 ShapeRectangle 的优势是合理的。你将在下一个示例中看到,当 SquareCircle 形状被添加到应用程序中时。图 4.5 显示运行应用程序呈现的屏幕与之前看到的一模一样——一个矩形在屏幕上弹跳并改变颜色。

图片

图 4.5 窗口绘图表面的矩形截图

多种形状

现在你已经定义了继承结构,你可以使用它来创建具有不同属性和行为的多种形状。将 SquareCircle 类添加到继承结构中是直接的。每个额外的类都从提供常用属性和方法作为新子类有用性的父类继承。

图 4.6 显示了继承结构的一些有趣元素。注意 Square 类是从 Rectangle 继承,而不是从 Shape 继承。这是因为正方形是矩形的一个特例,其高度和宽度相等。

图片

图 4.6 屏幕上绘制的形状之间的关系

这引发了一个关于继承和对象之间关系概念的问题。正如刚才提到的,SquareRectangle的一个实例,RectangleShape的一个实例,这意味着Square也是Shape的一个实例。以下是Square类的类定义代码:

class Square(Rectangle):

    def __init__(
        self,
        x: int,
        y: int,
        size: int,
        pen_color: tuple = COLOR_PALETTE[0],
        fill_color: tuple = COLOR_PALETTE[1],
        dir_x: int = 1,
        dir_y: int = 1,
        speed_x: int = 1,
        speed_y: int = 1,
    ):
        super().__init__(
            x, y,
            size,
            size,
            pen_color,
            fill_color,
            dir_x,
            dir_y,
            speed_x,
            speed_y
        )

即使Square的父类Rectangle类没有__init__()方法,Square类也有一个__init__()方法。Square提供这个独特的__init__()方法,因为它只需要一个单维值——size——而不是高度和宽度。然后它使用__init__()方法中的参数,在调用super().__init__()时。因为Rectangle类没有__init__()方法,所以super().__init__()调用Shape类构造函数,将size参数传递给heightwidth以设置属性维度。

super()方法是显式调用子类父类的__init__()方法的方式。Square类不需要提供draw()方法,因为它从父Rectangle类继承的方法已经足够好,只是高度和宽度属性具有相同的值。

CircleShape的一个实例,因为它直接从Shape类继承。创建Circle类的代码如下:

class Circle(Shape):

    def __init__(
        self,
        x: int,
        y: int,
        radius: int,
        pen_color: tuple = COLOR_PALETTE[0],
        fill_color: tuple = COLOR_PALETTE[1],
        dir_x: int = 1,
        dir_y: int = 1,
        speed_x: int = 1,
        speed_y: int = 1,
    ):
        super().__init__(
            x,
            y,
            radius * 2,
            radius * 2,
            pen_color,
            fill_color,
            dir_x,
            dir_y,
            speed_x,
            speed_y,
        )

    def draw(self):
        radius = self.width / 2
        center_x = self.x + radius
        center_y = self.y + radius
        arcade.draw_circle_filled(
            center_x,
            center_y,
            radius,
            self.fill_color
        )
        arcade.draw_circle_outline(
            center_x,
            center_y,
            radius,
            self.pen_color,
            3
        )

Square类类似,Circle类也提供了自己的__init__()方法,以便调用者可以为圆提供半径。radius参数在super().__init__()调用中使用,以设置圆将在其中绘制的区域的高度和宽度维度。与Square类不同,Circle类确实提供了一个独特的draw()方法,因为它在 arcade 模块中调用不同的绘图函数来在屏幕上绘制自身。当CH_04/example_05应用程序运行时,它创建了一个窗口,窗口内有三个不同形状在窗口内弹跳并改变颜色。最初,它看起来类似于图 4.7。

图 4.7 屏幕上绘制的所有三个形状

面向对象的家谱

面向对象的继承是一个允许你创建有用且强大的类层次结构的功能。记住,就像人类的家谱一样,随着你向下追溯树状结构,后代会越来越不像根父类。

大型的类层次结构可能会变得复杂,难以使用和理解,根类的功能可能会在遥远的子类中完全被掩盖。在我的工作中,我从未在我的构建的类层次结构中超过三个级别的父-子关系。

4.1.4 多态

继承的另一个特性,称为多态,在创建类层次结构时可能很有用。多态这个词的意思是“多种形式”,在编程中,它意味着通过相同的方法名调用多个对象的方法,但根据使用的对象实例不同,会得到不同的行为。

examples/CH_04/example_05 应用程序在渲染窗口中的不同形状时已经利用了多态性。程序中的每个形状都支持一个 draw() 方法。Rectangle 类提供了一个 draw() 方法来在应用程序屏幕上渲染自身。Square 类使用继承的 Rectangle draw() 方法,但限制了高度和宽度以创建一个 SquareCircle 类提供了一个自己的 draw() 方法来渲染自身。Shape 根父类还提供了一个没有功能的抽象 draw() 方法。

因为 RectangleSquareCircle 类都与 Shape 类存在 IS-A 关系,所以它们都可以被视为 Shape 的实例,并可以使用该类提供的方法。这就是在 Display 类中调用 on_update()on_draw()change_colors() 方法时发生的情况。Display 类在 __init__() 方法中创建了一个包含形状的 self.shapes = [] 列表。例如,以下是 on_draw() 方法中的代码:

def on_draw(self):

    # Clear the screen and start drawing
    arcade.start_render()

    # Draw the rectangles
    for shape in self.shapes:
        shape.draw()

当系统想要在屏幕上绘制对象时,会调用此代码,使用 arcade 模块时,大约每秒 60 次。代码利用多态性遍历形状列表,并调用每个形状的 draw() 方法。每个形状的不同并不重要;它们都支持 draw() 方法,并且所有形状都显示在屏幕上。可以从 Shape 类派生出任意数量的不同形状,只要它们支持一个在屏幕上渲染形状的 draw() 方法,上述循环就会工作。

现实世界的对象不应该限制对象设计

在描述继承时,使用现实世界中的对象或概念进行类比是很常见的。所提供的示例正是这样做的,使用了形状、矩形、正方形和圆的概念。使用你已经熟悉的概念是一个有用的隐喻,因为它们是你已经知道的东西。在讨论继承时,还会介绍许多其他新的概念;使用熟悉的想法可以减少学习时的认知负担。

然而,这个隐喻可能会妨碍你在应用程序中创建有用的类层次结构。因为我们一直在谈论具有现实世界中实际对象行为的事物,这可能会影响你对类设计的思考。你从设计的类中创建的对象不需要模拟现实世界中的对象。许多有用的类模型对象在现实世界中没有对应物,过于严格地遵循类比可能会阻碍你试图完成的工作。

4.1.5 组合

在继承部分,你看到了RectangleSquareCircleShape类之间的关系。这些关系允许子类从其父类继承属性和行为。这产生了RectangleShape的 IS-A 关系,SquareRectangle的 IS-A 关系,这也意味着Square也是Shape的 IS-A 关系。

这些关系还暗示了父类和从它们继承的子类的属性和行为之间的一定相似性。但这并不是将属性和行为纳入类中的唯一方法。

看一下Shape类;它有两个用于笔和填充颜色的属性。这两个属性为形状提供颜色,并且通过它们的名称区分彼此。但它们提供的是相同的东西——一种颜色,很可能是系统可以创建的颜色调色板中的颜色。这意味着颜色是Shape本身的一个公共属性,并且被表达两次。

使用继承可以处理这个问题,并通过创建具有笔和填充颜色属性的Color类并在其中继承Shape类来在示例中添加到层次结构中。这会起作用,但继承感觉有些不自然。你可以在代码中使Shape类与Color类有 IS-A 关系,但从逻辑上讲,这并不合理。形状不是颜色,它不符合继承结构中 IS-A 的心理模型。

而不是试图通过继承来强制提供所需的行为,你可以使用组合。你已经在使用组合,当给类属性赋予整数和字符串时。你可以更进一步,创建自定义类作为属性,将行为组合到自己的类中。

创建一个新的Color类为应用程序中的颜色提供了一致的抽象。它有一个类级别的颜色定义,并有一个机制来允许只设置定义的颜色。Color类作为组合与Shape类连接,如图 4.8 中用填充黑色菱形符号的连接线所示。

下面是Color类在examples/CH_04/example_06应用程序程序中的样子:

@dataclass
class Color:

    PALETTE = [
        arcade.color.BLACK,
        arcade.color.LIGHT_GRAY,
        arcade.color.LIGHT_CRIMSON,
        arcade.color.LIGHT_BLUE,
        arcade.color.LIGHT_CORAL,
        arcade.color.LIGHT_CYAN,
        arcade.color.LIGHT_GREEN,
        arcade.color.LIGHT_YELLOW,
        arcade.color.LIGHT_PASTEL_PURPLE,
        arcade.color.LIGHT_SALMON,
        arcade.color.LIGHT_TAUPE,
        arcade.color.LIGHT_SLATE_GRAY,
    ]
    color: tuple = PALETTE[0]
    _color: tuple = field(init=False)

    @property
    def color(self) -> tuple:
        return self._color

    @color.setter
    def color(self, value: tuple) -> None:
        if value in Color.PALETTE:
            self._color = value

图片

图 4.8 类的 UML 图,包括组合的Color

Color类将允许的颜色列表移动到类的范围内,并从全局模块命名空间中移出。它也是一个 Python 数据类,这使得定义主要是数据的简单类更容易实现。该类提供了获取器和设置器属性装饰器,使得在类中使用颜色更加直接。

Shape类被修改为使用Color类作为笔和填充颜色属性。这里显示了类的更新后的__init__()方法:

class Shape:

    def __init__(
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        pen: Color = Color(),
        fill: Color = Color(),
        dir_x: int = 1,
        dir_y: int = 1,
        speed_x: int = 1,
        speed_y: int = 1,
    ):
        self._x = x
        self._y = y
        self.width = width
        self.height = height
        self.pen = Color(Color.PALETTE[0])
        self.fill = Color(Color.PALETTE[1])
        self.dir_x = 1 if dir_x > 0 else -1
        self.dir_y = 1 if dir_y > 0 else -1
        self.speed_x = speed_x
        self.speed_y = speed_y

笔和填充颜色的属性名称已经被简化为仅penfill,因为它们都是Color类的实例。初始默认值已设置为笔的颜色为黑色,填充颜色为浅灰色。以这种方式将Color类添加到Shape类中创建了一个 HAS-A 关系;Shape类具有Color属性,但它本身并不是一个颜色。

set_pen_color()set_fll_color()方法也已被修改为使用新的penfill属性。设置笔的颜色现在看起来是这样的:

def set_pen_color(self, color: tuple) -> Rectangle:
    self.pen.color = color 
    return self

运行examples/CH_04/example_06应用程序会产生一个与之前看到的屏幕完全一样的效果——三个形状在窗口中弹跳,并且每秒改变颜色。使用组合为你提供了一种在不创建人为的继承层次结构的情况下向类添加属性和行为的方法。

4.2 结束语

创建类和类层次结构为你提供了一种创建干净且使用受控的代码的方式。类是向用户提供应用程序功能 API 的另一种途径。

类定义也可以用来创建命名空间和控制作用域。一个模块提供了一个命名空间,在该模块中定义的类创建了更多的命名空间。类的属性和方法在由该类创建的实例对象的作用域内。

你已经接近了开发者领域的特定元素,现在正在使用双筒望远镜来获得这些领域的更详细视图。我们将继续在我们的旅程中扫描有用的和强大的领域,以获得洞察力。

摘要

  • 面向对象编程(OOP)让你能够将数据与其上的功能封装在一起。这给对象带来了“行为”,使得思考这些对象如何与其他对象反应和交互变得稍微容易一些。

  • Python 使用类定义来创建对象的架构。一个类给对象提供了一个外观和感觉;类的实例是你在代码中对类的实现。就像饼干模具定义了饼干是什么一样,饼干是饼干模具定义的实例。

  • 类似于命名空间,类可以通过从父类到子类的继承来创建层次结构。子类从其父类继承属性和功能,并可以添加自己独特的新功能。

  • 使用组合,可以在不创建尴尬或不合逻辑的继承结构的情况下向类设计添加属性和功能。

  • 你已经学习了关于类、继承以及创建类层次结构的内容。使用类是利用代码重用并遵循 DRY 原则的强大方式。

  • 通过使用组合,你可以向定义中添加额外的属性和功能,这些属性和功能不是来自继承,从而避免尴尬和不合逻辑的继承层次结构。

5 个异常事件

本章涵盖

  • 异常是什么

  • 为什么它们会在程序中发生

  • 如何处理异常

  • 如何抛出异常

  • 创建自定义异常

开发软件可能会让你以二进制的方式思考——事物要么开启要么关闭;要么工作要么不工作;某事要么是真的要么是假的。然而,现实世界远非二进制;它是一个充满多样性的海洋。你为自己和他人创建的软件就生活在这个世界中。

真实世界不是非黑即白;它是一个介于这两个极端之间的无限可变领域。运行软件的计算机系统会失去电力并失败。连接系统的网络可能缓慢、间歇或不稳定。依赖于软件保存信息的存储系统可能会满载、不可靠或失败。你的软件用户可能会做出错误的假设并输入错误或误导性的数据。

除了软件运行的具有问题的世界之外,你还会在编写的代码中引入错误。软件错误是错误或失败,导致应用产生意外的结果。大多数提供有用功能的软件应用都足够复杂,以至于会存在错误。这些错误来自开发者的错误假设、疏忽和简单的日常错误。

这不应该让你作为开发者感到气馁,而应该拓宽你思考创建软件应用的方式。前面概述的问题可以管理和处理,并且是成为开发者挑战的一部分。你如何处理这些挑战取决于应用的需求及其用户。

如果你只是快速编写一个程序来解决自己的问题,创建一个如果输入不正确可能会崩溃的程序可能是可以接受的。另一方面,如果你正在创建成百上千用户会访问的程序,程序崩溃就远不可接受了。处理可能出现的条件需要更长的时间来开发和构建代码。图 5.1 显示了与软件开发应用相关的三个事物之间的关系。

图片

图 5.1 软件开发现实三个方面的韦恩图

在这个语境中,“好”意味着软件应用质量良好,满足需求和用户期望,并且错误发生频率低。快速和便宜的定义稍微有些困难,因为它们与时间和金钱相关,正如俗话所说。

好和便宜的交集代表了开发软件应用所需的时间,表明这可能需要更多的时间来创建。这可能发生在没有经验的开发者或一个小型开发团队相对长时间地努力创建一个良好的应用时。

好与快的交集代表时间,并表明快速、高质量地创建应用程序。这几乎总是意味着更有经验的开发者可以在相对较短的时间内创建应用程序。

快与便宜的交集涉及到在快速创建应用程序的同时在质量上进行权衡。以这种方式创建的应用程序可能适用于一次性实用程序,但通常应避免。

即使图表显示了三个圆的交集,但只有两个圆的交集是可能实现的。三个圆的交集是神奇独角兽居住的地方。试图找到通往独角兽之地的路径会让你无法真正创造出有用的东西。

软件应用程序以良好的质量满足用户的需求,以几种方式。它提供了用户期望的功能,并在出错时不会以意外的方式表现。

如本章引言中所述,在现实世界中,意外事件随时都在发生。存储设备会满,网络会断开,用户会输入错误的数据,无论是无意还是有意。意外事件可能随时发生,Python 通过引发异常来处理。

5.1 异常

Python 通过引发异常来处理运行中的应用程序中的意外事件。你可以将这些视为应用程序运行期间发生的异常条件,并且它们并不一定都是错误。

如果你编写过任何 Python 程序,你一定见过引发的异常。程序语句中的简单数学错误会引发异常,如下所示:

>>> print(10/0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

Python 会引发ZeroDivisionError异常,因为将 10 除以 0 的结果是未定义的。ZeroDivisionError异常是基本Exception类的子类或子类。Exception类是 Python 可以引发的大多数其他异常的父类。

以这种方式,Exception类就像上一章中Shape类是Rectangle类的父类一样。ZeroDivisionError异常是更通用的基本Exception类的更具体版本。

当程序中发生异常时,Python 将停止执行你的程序代码,并开始沿着调用栈向上查找异常处理程序。调用栈是导致引发异常的代码的一系列函数调用。Python 正在寻找一个处理程序来拦截异常并对其进行处理。如果没有找到异常处理程序,Python 将退出应用程序并打印堆栈跟踪。

堆栈跟踪是从应用程序的根开始,按时间顺序向下列出调用栈中调用的函数,直到到达代码中引发异常的点。堆栈跟踪中列出的每个函数也显示了调用堆栈中函数所在的模块中的行号。堆栈跟踪一直持续到最后一个函数被调用,此时显示引发异常的行号。示例程序 examples/CH_05/example_01.py 展示了这一点:

def func_a():
    dividend = float(input("Enter a dividend value: "))
    divisor = float(input("Enter a divisor value: "))
    result = func_b(dividend, divisor)
    print(f"dividing {dividend} by {divisor} = {result}")

def func_b(dividend: float, divisor: float) -> float:
    return func_c(dividend, divisor)

def func_c(dividend: float, divisor: float) -> float:
    return dividend / divisor

func_a()

这个程序显示了 func_a() 从用户那里获取 dividenddivisor 的输入,并将这些输入字符串转换为浮点值。然后它调用 func_b()func_b() 又调用 func_c(),在传递的两个参数上执行除法操作。运行此程序会在提示中输入的值产生以下输出:

Enter a dividend value: 12.2
Enter a divisor value: 0
Traceback (most recent call last):
  File "<path to code>/code/project/CH_05/example_01.py",
  ➥ line 20, in <module>
    func_a()
  File "<path to code>/code/project/CH_05/example_01.py",
  ➥ line 8, in func_a
    result = func_b(dividend, divisor)
  File "<path to code>/code/project/CH_05/example_01.py",
  ➥ line 13, in func_b
    return func_c(dividend, divisor)
  File "<path to code>/code/project/CH_05/example_01.py",
  ➥ line 17, in func_c
    return dividend / divisor
ZeroDivisionError: float division by zero

代码显示 Python 在尝试将 12.2 除以 0 时在 func_c() 函数中遇到异常,然后沿着调用栈向上到 func_b(),再向上到 func_a(),寻找处理程序来拦截异常。因为没有处理程序,Python 退出程序并打印出导致应用程序崩溃的异常和堆栈跟踪。

提示:我在上面的程序输出和堆栈跟踪中插入了 <path to code>,因为显示的代码路径与我使用的 Mac 相关,并且与你在计算机上运行代码时看到的不一样。

程序可能引发的另一个可能的异常是,如果用户在两个提示中的任何一个输入了一个无法转换为浮点值的字符串。以下是一个运行程序引发该异常的示例:

Enter a dividend value: Python
Traceback (most recent call last):
  File "<path to code>/project/CH_05/example_01.py", line 20, in <module>
    func_a()
  File "<path to code>/code/project/CH_05/example_01.py", line 6, in func_a
    dividend = float(input("Enter a dividend value: "))
ValueError: could not convert string to float: 'Python'

在这个例子中,堆栈跟踪只显示了 func_a(),因为当程序尝试将字符串 Python 转换为浮点值时,ValueError 异常在该函数内部引发。

5.2 处理异常

在 Python 中处理异常是通过在程序代码中使用 try / except 块来完成的:

try:
    # code that might raise an exception
except Exception as e:
    # code that executes if an exception occurs
else:
    # code that executes if no exception occurs (optional)
finally:
    # code that executes whether an exception occurs or not (optional)

try 语句开始一个可能引发程序可以处理的异常的代码块。except Exception as e: 语句结束代码块,并且是在这里拦截异常并将其分配给 e 变量的地方。使用 e 不是必需的语法,变量名 e 只是我的惯例。

由于处理程序部分中的 except Exception as e:,前面的示例将捕获 try / except 块内引发的任何异常。try / except 块的 elsefinally 子句是可选的,并且在实践中使用较少。

5.2.1 如果代码可以处理异常,则处理异常

当考虑异常时,很容易陷入一种处理它们可能出现的任何地方的心态。根据异常在程序中的发生位置,这可能是逻辑上的选择。

通常,异常发生在代码作用于传递给它的参数的函数上下文中。在这种情况下,代码执行的工作范围很窄,而程序试图实现更广泛的上下文则处于更高层级。

提示:如果您使用“单一职责”的概念,那么函数可能对应用程序创建的更大图景了解不多。

当函数中发生异常时,异常处理器可以在函数的上下文中做出合理的选择。处理器可能能够在让异常在调用堆栈中向上传播到更高上下文之前,尝试重试操作固定次数。它可以根据异常做出假设,并纠正或更改数据的状态,使代码继续执行而不会引发异常。

5.2.2 允许异常向上传播到程序中

如果异常发生的代码无法对异常采取任何有用的措施,那么最好让异常通过调用堆栈向上传播到更高层级的上下文中。在更高层级的上下文中,可以做出关于如何处理异常的决定。更高层级的上下文可能是做出重试操作选择的地方。在更高层级的上下文中,可能可以获得更多关于程序试图实现什么以及可以采取哪些替代路径的信息。在这种情况下,您可以决定向用户展示哪些信息,以便他们可以决定如何继续操作。

程序还应记录异常及其关联的堆栈跟踪,以便应用程序开发者了解导致异常的路径。当调试应用程序并尝试解决问题时,这组信息非常有用。

在代码中记录异常的位置部分取决于异常是否被处理。如果异常被处理,可能没有必要记录有关它的信息。然而,输出一条日志消息可能是有用的,这不是错误,而是信息级别,以便使其可见。

也可能存在异常对程序是致命的,除了记录异常堆栈跟踪和退出程序之外,无法采取任何其他措施。对于某些应用程序,如实用程序和命令行工具,退出应用程序是完全合理的行动方案。

5.2.3 通知用户

保持应用程序用户了解应用程序的状态和其中发生的事件也是有用的。在应用程序流程的正确上下文中,异常处理器可以通知用户采取纠正措施,允许应用程序重试操作并成功。异常的类型以及附加到其上的消息可以帮助生成一条信息性消息,向用户展示。

5.2.4 永远不要静默异常

有可能处理异常并抑制它。抑制异常在以下两个示例中展示:

try:
    # some code that might raise an exception
except:
    pass
try:
    # some code that might raise an exception
except Exception:
    pass

第一个示例捕获所有异常,包括系统事件和键盘事件,如 CTRL-C 退出程序,这会生成 KeyboardInterrupt 异常。这是一个系统异常,并不一定是错误,而是一个异常事件。第二个示例捕获更窄范围的异常,其中许多可以被认为是错误条件,但它仍然过于宽泛。

捕获过于广泛的异常类别比这更糟糕,之前的代码让异常无声地通过。它没有通知用户或记录异常堆栈跟踪。用户被剥夺了了解应用为何出现故障的信息,开发者也没有得到任何有关异常是什么或它发生在哪里的信息。

存在这样的代码块之一是低质量应用的标志。试图在应用中找到这种代码模式的问题源头是令人沮丧且耗时的。

示例 1 改进

以下示例代码基于之前的讨论,展示了何时允许异常向上传递到调用栈,以及何时可以使用异常处理器尝试纠正导致异常的情况。程序 examples/CH_05/example_01.py 可以改进以处理异常并提供更好的用户体验。程序 examples/CH_05/example_02.py 展示了这种改进:

def func_a():
    dividend = float(input("Enter a dividend value: "))
    divisor = float(input("Enter a divisor value: "))
    result = func_b(dividend, divisor)
    print(f"dividing {dividend} by {divisor} = {result}")

def func_b(dividend: float, divisor: float) -> float:
    return func_c(dividend, divisor)

def func_c(dividend: float, divisor: float) -> float:
    return dividend / divisor

successful = False
while not successful:
    try:
        func_a()
    except ZeroDivisionError as e:
        print(f"The divisor can't be a zero value, error:", e)
    except ValueError as e:
        print(
            f"The dividend and divisor must be a string that represents a
            ➥ number, error:",
            e,
        )
    else:
        successful = True
    finally:
        if successful:
            print("Thanks for running the program")
        else:
            print("Try entering a dividend and divisor again")

在这个例子中,函数 func_a()func_b()func_c() 都没有改变,并且不捕获异常。它们遵循让异常向上通过堆栈传递到更高级别上下文的模式。

这一级别的上下文是 func_a()。现在有一个围绕该函数的 while 循环,它会不断尝试执行 func_a(),直到成功完成。

在 while 循环内部,有一个异常处理器捕获两个异常,ZeroDivisionErrorValueError。这两个处理器都会向用户提供有关出错的信息,并提供有关如何继续的建议。

处理器的 else 子句仅在 func_a() 成功运行且未引发异常时执行。当发生这种情况时,它将 successful 变量设置为 True,向包围的 while 循环发出退出信号。

finally 子句利用 successful 变量的状态来指示程序已完成或鼓励用户再次尝试。运行此程序并从用户可能的输入来看,情况如下:

Enter a dividend value: Python
The dividend and divisor must be a string that represents a number, error: could not convert string to float: 'Python'
Try entering a dividend and divisor again
Enter a dividend value: 12.2
Enter a divisor value: 0
The divisor can't be a zero value, error: float division by zero
Try entering a dividend and divisor again
Enter a dividend value: 12.2
Enter a divisor value: 3.4
dividing 12.2 by 3.4 = 3.5882352941176467
Thanks for running the program

该程序遵循了处理异常的大部分建议:

  • 允许无法在本地处理的异常向上流动到更高上下文

  • 如果代码可以对此异常做些有用的事情,则处理异常

  • 通知用户问题并提出解决方案

  • 不抑制异常

程序没有记录异常和堆栈跟踪,因为对于这个程序来说,这些信息可能会分散用户的注意力。这个程序将异常处理作为其预期程序流程的一部分,这使得记录异常信息变得不必要。

这并不是说,如果认为有用,这些信息不能被添加到 ZeroDivisionErrorValueError 的处理程序中。可以使用以下方式使用 Python 的日志模块来处理异常:`

import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
formatter = logging.Formatter(
    '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
ch.setFormatter(formatter)
logger.addHandler(ch)

try:
    x = 10 / 0
except Exception as e:
    logger.error("something bad happened")

这段代码导入了 logging 模块,并创建了一个以创建实例的模块命名的简单 logger 实例。然后它将终端设置为日志消息的输出,创建了一个用于消息外观的格式化器,并将处理程序添加到日志记录器实例中。更全面地配置日志记录器将是后续章节示例的一部分,当我们开始构建 MyBlog 网络应用程序时。

在异常处理程序中调用 logger.error() 将会打印消息。该消息将被格式化,看起来像这样:

2022-07-23 18:50:29,803 - __main__ - ERROR - something bad happened

输出包括异常发生的时间戳、模块名称、错误级别和消息。在先前的例子中,__main__ 名称存在是因为示例作为独立应用程序运行。

如果代码作为一个模块被导入到更大的应用程序中,__main__ 将会被替换为模块的名称。在更大的应用程序中,这个信息有助于缩小异常发生的位置。

在这个简单的例子中,日志记录器的输出被定向到 stdout,对于大多数用户来说,这代表控制台或屏幕。大多数日志记录器的使用案例都是将输出定向到日志文件或日志聚合系统,以防止日志信息干扰程序为用户产生的任何输出。

本节介绍中提到,异常永远不会被静默处理,这是处理异常的主要正确方式。然而,在某些情况下,如果开发者对代码的了解使得在深思熟虑的情况下静默处理异常是可以接受的,那么上述建议仍然适用。

5.3 抛出异常

在 Python 中,你可以在代码中以编程方式抛出异常。抛出异常可能看起来像是一件奇怪的事情,因为大多数时候异常等同于错误。然而,抛出异常是处理程序中你决定是异常而不是程序性错误的条件的有效方式。

例如,假设你正在编写一个提供基于用户输入的计算的测验应用程序。其中一个计算函数仅当用户输入的参数大于 0 且小于或等于 100 时才有效。代码应该定义一个该函数可接受值的范围。

由于 Python 中的整数范围很大,你的程序代码需要将用户输入限制在 0 < value ≤ 100 的范围内。在函数的使用点限制范围是足够容易的,但函数如果违反了范围限制,应该怎么做呢?

最有可能的是,函数应该什么都不做,因为它没有上下文来执行任何有用的操作,如果范围限制被违反。考虑到让异常向上流动到可以处理它们的层级,抛出异常是有用的。这里有一种处理函数中范围限制的方法:

def range_check_user_input(value):
    if not 0 < value <= 100:
        raise ValueError("value range exceeded", value)
    # additional functionality

在函数的最顶部,一个条件语句检查value参数是否不在可接受范围内,如果不是,则抛出一个ValueError异常。如果参数在范围内,函数将正常继续执行。

代码将处理超出范围的ValueError异常的责任委托给调用栈上的调用函数。调用函数很可能具有处理异常所需的上下文,可能通过提示用户再次输入value参数来实现。

在调用函数中处理ValueError异常可能如下所示:

def get_data_from_user():
    # initialization and gather user input
    try:
        range_check_user_input(value)
    except ValueError as e:
        print(e)
        # restart code to get user input

get_data_from_user()函数在try / except块中调用range_check_user_input()函数,该块处理ValueError异常,向用户打印错误信息,并重新启动过程以获取用户数据。

5.4 创建自己的异常

Python 允许你创建自定义异常类,你的代码可以抛出这些异常。创建自定义异常可能看起来是不必要的,因为 Python 已经定义了一套丰富的异常类。创建自定义异常有几个很好的理由:

  • 异常命名空间创建

  • 异常过滤

在前面的章节中,range_check_user_input()函数抛出了ValueError,因此get_data_from_user()函数中更高层次的异常处理程序可以拦截并处理它。但是,假设range_check_user_input()函数在函数的后面抛出了不相关的ValueError。异常将通过调用栈向上流动到调用函数,并被get_data_from_user()中的异常处理程序捕获。

在那个点上,get_data_from_user()函数应该做什么?代码不能假设正确的行为是向用户显示错误并重新启动收集数据的过程,因为范围检查不是异常的唯一可能来源。

一种选择是通过查看e.args属性元组来检查异常参数。然后代码可以在异常处理程序中做出选择,以确定异常的来源。这种解决方案是脆弱的,因为它依赖于在ValueError抛出时的参数,而这些参数可能会在以后改变。

更好的设计是创建一个针对程序需求的特定异常,缩小异常处理的范围。你可以在定义range_check_user_input()函数的模块中创建一个自定义异常处理器,如下所示:

class OutsideRangeException(Exception):
    pass

def range_check_user_input(value):
    if not 0 < value <= 100:
        raise OutsideRangeException(“value range exceeded”, value)
    # additional functionality

此代码创建了一个名为OutsideRangeException的新异常类,它继承自父类Exception。这个新异常类在range_check_user_input()函数中使用,并在value参数超出定义的可接受值范围时引发。现在简化的程序代码看起来像这样:

class OutsideRangeException(Exception):
    pass

def get_data_from_user ():
    # initialization and gather user input
    try:
        calculated_result = calculate(value)
    except OutsideRangeException as e:
        print(e)
        # restart code to gather user input

def range_check_user_input (value):
    if not 0 < value <= 100:
        raise OutsideRangeException("value range exceeded", value)
    # additional functionality

如果参数value超出了可接受的范围,get_data_from_user()函数可以捕获这个特定的自定义异常并处理它。如果range_check_user_input()函数引发任何其他异常,该异常将向上传递到捕获该特定异常(或只是基类Exception)的处理程序。下面是一个使用日志模块的完整示例程序,展示了这一点,为了简洁起见,注释已被移除:

import math
import logging

logger = logging.getLogger(__file__)

class OutsideRangeException(Exception):
    pass

def range_check_user_input(value: int) -> float:
    if not 0 < value <= 100:
        raise OutsideRangeException("range exceeded", value)
    return value

def get_data_from_user():
    successful = False
    while not successful:
        value = input(
            "Please enter an integer greater than 0 and less than "
            "or equal to 100: "
        )

        try:
            value = int(value)
        except ValueError as e:
            logger.exception("Something happened", e)
            print(e)
            continue
        try:
            result = range_check_user_input(value)
        except OutsideRangeException as e:
            logger.exception("value outside of range", e)
            print(
                "Entered value outside of acceptable range,"
                " please re-enter a valid number"
            )
            continue
        print(f"value within range = {result}")
        successful = True

def main():
    get_data_from_user()

if __name__ == "__main__":
    main()

5.5 结束语

理解如何处理和使用异常对于一个开发者来说至关重要。它们是我们程序从现实世界接收到的关于程序发生的事情以及程序采取的行动的结果的事件。

开发的一个目标是创建对世界有用的东西。异常以及我们如何处理它们是允许你开发出在这个世界上将被使用和良好接受的成功的程序的工具。

异常和异常处理是开发者世界的一部分。仔细观察它们并了解它们的细节,可以帮助你更好地使用异常,正如我们在检查应用程序开发的其它方面时一样。

摘要

  • 世界充满了事件——有些是预期的,有些是意外的。应用程序也受到预期和意外事件的困扰,这些事件表现为异常。异常以及它们在应用程序中的处理是开发者生活的一部分。

  • 在 Python 中,异常是通过在应用程序中引入 try/except 代码块来处理的。这些 try/except 代码块为我们提供了捕获异常并处理它们(如果需要的话)的方法。

  • 在应用程序中捕获异常的位置通常取决于代码中存在处理异常事件的有用上下文的位置。

  • 生成异常并不总是关于错误。异常既不是好的也不是坏的;它只是一个事件。它们可以用来向应用程序的其他部分发出信号,这些部分可能与你的应用程序设计相关。

第二部分。实地工作

在掌握基础技能之后,你将准备好踏上依赖这些技能的道路。在这一部分,你将采取逐步的方法来构建一个功能齐全且外观美观的博客应用程序。

第一步是第六章,你将使用 Flask 框架创建一个网络应用程序。你将看到如何使用 Jinja2 模板可以极大地简化你的工作。

第七章全部关于给你的第一个应用程序应用样式并重构它,使其扩展变得可管理。

第八章介绍了认证的入门知识。这允许你授予想要使用你的网络应用程序的用户访问权限,其中很大一部分是在你的网络应用程序中创建注册新用户的页面。

在第九章中,我们通过将其与授权连接来进一步探讨认证。授权完全是关于定义注册用户可以在网络应用程序中做什么,这保护了他们、其他用户以及应用程序本身。

第十章探讨了可以使用数据库做什么以及它们在持久化数据方面的有用性。你还将了解什么是 ORM(对象关系映射器)以及它如何与 Python 完美契合,以及如何访问数据库。

最后,第十一章将所有实地工作步骤汇总在一起,构建网络应用程序的主要功能,创建博客内容并允许用户对其发表评论。当你完成第十一章时,你将拥有丰富的强大 Python 工具,准备好承担任何数量的新项目!

6 在互联网上分享

本章涵盖了

  • 项目应用

  • Web 服务器的作用

  • Flask 微框架

  • 运行服务器

在前面的章节中,我们讨论了很多关于成为一名开发者的内容。现在,我们将把这些知识付诸实践。选择要创建的应用程序是棘手的,因为可能性几乎是无限的。你将创建的项目是一个小型但功能齐全的博客平台,我们将称之为 MyBlog。MyBlog 应用程序将以基于 Web 的 Python 应用程序的形式提供。

MyBlog Web 应用程序将为用户提供工具,使他们能够加入博客社区并创建博客文章。注册用户可以使用 Markdown 进行样式设置来发布内容。所有用户都将能够查看发布的内容,注册用户将能够对其进行评论。管理员用户将能够根据需要标记任何内容或评论为活动/非活动状态。注册用户将能够标记他们创建的任何内容为活动/非活动状态。

6.1 分享你的工作

MyBlog Web 应用程序不仅是一种分享你的想法和应用程序用户的想法的方式,也是一种分享你工作的方式。该应用程序提供了一系列功能,服务于特定的目的。将技术整合在一起以创建 MyBlog 功能所涉及的工作是一项值得展示的技能。

6.1.1 Web 应用程序的优势

创建 Web 应用程序的项目选择基于几个考虑因素。首先,创建一个有用的 Web 应用程序很好地建立在前面章节所涵盖的主题之上。将开发工具、命名和命名空间、API 的使用和创建以及类设计等主题结合起来,将对应用程序的整体图景产生影响。

其他类型的应用程序也提供了一些表达你所学知识的机会,但与他人分享可能具有挑战性。例如,创建桌面 GUI 应用程序对开发者来说提出了有趣的挑战。然而,广泛分发 GUI 应用程序可能很困难。当然,使用 Python 可以做到这一点,但必要的步骤超出了本书的范围。

在分发应用程序方面,Web 服务器有一些优势。Web 服务器本身以及其功能和服务的位置是集中的,并且不运行在用户所拥有的众多不同的计算机环境中。以这种方式集中服务器意味着应用程序的更改和更新发生在同一个地方。重新启动服务器或交互式推送更改,可以使更改和更新立即对所有用户可用。

基于网络服务器的应用另一个优势是提供用户界面。网络应用利用了几乎所有计算机上安装的浏览器。现代网络浏览器提供了一个强大的平台,可以用来构建用户界面。数据可以以几乎无限的方式格式化和展示。图像和多媒体也得到了很好的支持。用户可以使用按钮、列表、下拉列表和表单等界面元素与托管在浏览器上的应用进行交互。

6.1.2 网络应用挑战

这并不是说使用网络浏览器作为应用平台没有挑战。创建网络应用意味着你将在多个技术领域工作。除了 Python,你还将创建 HTML、CSS 和 JavaScript 代码文件。此外,基于桌面的应用提供了对计算机硬件的直接访问,以及个人计算机带来的巨大计算能力。

然而,随着浏览器、新兴和扩展的网络技术的持续进步,以及广泛可用的互联网速度的不断提高,桌面和基于网络的应用性能差距正在缩小。此外,基于网络的系统已经广泛接受为交付应用的方法。这种接受使得创建它们成为个人和职业发展的有效途径。

提示:无法过分强调网络开发技能的重要性和价值。随着越来越多的用户从桌面设备迁移到利用互联网普遍性的移动设备,这将变得更加有价值。

现在已有可用的博客平台供您使用或下载,您可以在自己的计算机上运行。MyBlog 应用不会与它们竞争,因为它的目的是提供一个有用的教学框架,为示例代码提供方向和目标。

MyBlog 提供的应用并非具有突破性的功能或技术;博客软件已被广泛理解。这是 MyBlog 的一个优势,因为它已经知道博客应用旨在提供什么。目标不是创建一个前沿的博客,而是要看到应用意图的整体图景,并以开发者的思维方式将必要的部分组合在一起,将这幅图景变为现实。

6.2 服务器

MyBlog 网络应用是服务器应用一般提供的子集。服务器的定义之一是运行在计算机上,或多台计算机上,为网络上的其他应用提供功能。这种多个应用访问中心服务器功能安排的模型被称为客户端-服务器模型。

当你构建 MyBlog 应用程序时,你将在本地计算机上运行它,本质上将其变成一个服务器。使计算机成为服务器的是运行在计算机上的软件,而不是计算机硬件配置。商业服务器硬件项目是为了优化服务器软件运行所需的访问而构建的,但除此之外,它们只是计算机。

我们都使用社交网络,并在我们的桌面或移动设备上运行程序。这些工具是使用许多服务器功能的客户端应用程序。如果你玩任何多人游戏,游戏应用程序将使用服务器的功能来协调游戏中所有玩家的动作。图 6.1 显示了多种设备通过网络连接到服务器。

图片

图 6.1 多种设备连接到服务器提供的功能

6.2.1 请求-响应模型

一种常见的服务器实现是请求-响应模型。客户端应用程序向服务器发送请求,服务器处理请求并返回响应。在这种类型的应用程序中,服务器除非被请求执行,否则不会采取任何行动。

在网络应用程序中,客户端浏览器向服务器发送 HTTP 请求以获取响应。响应通常是表示 HTML 页面的文本流。当客户端浏览器接收到响应时,它将在浏览器窗口中渲染 HTML。根据创建页面的 HTML 代码,它可能还需要向服务器请求信息,如 CSS(层叠样式表)和 JavaScript 文件。

图 6.2 表示了客户端和服务器之间随时间发生的请求-响应通信的简化视图。最初,客户端向服务器发送请求,服务器可能需要从数据库检索数据以组成响应。当响应创建完成后,它将被传回客户端。

在本例中,响应是一个客户端应用程序将在浏览器窗口中渲染的 HTML 页面。图 6.2 展示了渲染 HTML 页面时的来回通信。HTML 代码的一部分包括链接到 CSS 和 JavaScript 文件,这些文件会向服务器生成额外的请求。服务器从服务器的硬盘上检索请求的文件,并将它们作为响应发送给客户端。请求-响应模型是 MyBlog 网络应用程序获取数据的主要方式,用于构建并向用户展示应用程序信息。

图片

图 6.2 构建网页的客户端和 Web 服务器交互事件流

6.3 网络服务器

网络服务器是一个响应来自客户端应用程序的 HTTP 请求的应用程序。网络浏览器是一个客户端应用程序,它向网络服务器发送请求并解释响应,并在屏幕上显示它们。通常发送到客户端网络浏览器的都是浏览器解释并渲染为网页的 HTML 文档。HTML 文档是客户端请求的内容。

客户端浏览器和网络服务器之间存在许多其他交互。浏览器可以请求服务器发送图片、音频和视频内容,甚至下载其他应用程序到客户端的计算机。

HTML 文档可以包含指向 CSS 和 JavaScript 文件的链接。当 HTML 被网络浏览器渲染时,嵌入到这些文件的链接会生成对网络服务器的额外 HTTP 请求。网络服务器通过发送请求的内容来响应。

提示:现代网络应用程序会向一个或多个服务器发送许多请求,以提供内容并渲染用户从网络应用程序中期望的体验。

CSS 文件包含应用于屏幕上显示的 HTML 文档中的内容样式信息。CSS 代码修改了网页的外观和感觉,是 HTML 内容的表现层。

JavaScript 文件包含在客户端浏览器中运行的代码。一旦下载,网络浏览器将开始执行 JavaScript 代码。此代码可以连接到屏幕上的按钮点击和显示更新,以及用户在网页上可以进行的几乎所有操作都可以由 JavaScript 代码处理。

JavaScript 代码也可以向网络服务器发送 HTTP 请求以获取文本和数据。这些请求可以由用户操作或编程方式启动,并可以动态更改和更新网页。

HTTP 请求

HTTP 协议定义不是本书的意图,也不在其范围之内,但一些基本信息是有用的。以下是一个向网络服务器发送的 HTTP 请求示例:

1 GET /path_part/index.xhtml HTTP/1.1
2 Host: fictional_website.com:80
3 Accept: text/html, */*
4 <CR-LF>

此示例中的行号不是请求的一部分,但被添加以参考协议解释中的行:

  1. GET /path_part/index.xhtml HTTP/1.1—这是向网络服务器发送请求的开始。单词 GET 表示要使用的 HTTP 方法——在这种情况下,使用 HTTP 协议版本 1.1 检索位于 /path_part/index.xhtml 的文档。

  2. Host: fictional_website.com:80—表示请求发送的域名、后缀和端口号。端口号(80)是可选的,如果不存在,则默认为 80。

    • 域名后缀是托管网站的服务器的文本名称。此名称由 DNS(域名服务器)翻译为 IP 地址,以便网络协议可以将请求定向到互联网上的正确服务器。

    • 后缀来自一个帮助管理和区分互联网上域名的后缀列表。你可能已经熟悉像“ .com”和“ .net”这样的后缀,但还有很多其他的。这使得“myserver.com”和“myserver.net”成为两个不同的名称。

    • 以太网网卡可以支持 65,353 个逻辑端口,其中从 0 到 1023 的端口被保留用于常见应用程序的已知端口。例如,Web 服务器通常使用端口 80,这也是为什么如果 URL 中没有指定,它就是默认端口。然而,服务器,包括 Web 服务器,可以在未使用的任何端口上运行。

  3. Accept: text/html, */*—这个头部信息是可选的。在这个例子中,它向服务器指示客户端可以接受哪些类型的响应。可以有多个头部,每个头部都包含来自客户端的附加信息,这些信息对 Web 服务器可能很有用。

  4. <CR-LF>—表示回车/换行字符或空白行,这是 HTTP 协议的必要部分,它结束了头部列表并告诉服务器开始处理请求。

当我刚开始接触 Web 开发时,让我惊讶的是,这里显示的少量文本,以及图 6.3 中分列出来的内容,实际上就是通过网络发送到服务器的。请求的前两行创建了一个 URL,即统一资源定位符,它唯一地标识了客户端请求的内容。

图 6.3 完整的 URL 唯一地标识了要检索的资源。

服务器接收到这个请求并采取以下步骤之一:

  • 将请求映射到服务器控制下的文件,并将其返回给客户端

  • 将请求映射到处理程序(程序代码),并将处理程序的输出返回给客户端

  • 确定请求无法回答并返回错误消息

使用斜线字符(/)与文件系统上目录和文件的路径分隔符斜线用法非常相似。这种模式是展示从根域名fictional_website.com起源的资源内容的逻辑路径层次结构的有用方式。

通过允许多部分路径,创建了一个逻辑层次结构。浏览器应用程序可以通过这个层次结构导航,以访问 Web 服务器的不同部分。URL 的端点可以是 Web 服务器提供的实际文件资源,但不必是。创建的逻辑路径可以与服务器文件系统上资源的实际文件路径没有关系。

6.4 Flask

您将使用 Flask 构建 MyBlog 应用程序,Flask 是一个用于 Python 的轻量级 Web 应用程序框架。Flask 提供了必要的机制和基础设施,使 Python 能够作为 Web 应用程序服务器来创建性能良好且可扩展的应用程序。Flask 包括处理 URL 资源的 HTTP 请求并连接到动态构建响应的 Python 代码的能力。

Flask (flask.palletsprojects.com/en/2.1.x/) 不是 Python 标准库模块的一部分,但作为 Python 包索引 (pypi.org/) 上托管的一个第三方模块而可用。像其他可用的 Python 模块一样,这使得它可以通过 pip 工具进行安装。

6.4.1 为什么选择 Flask?

Python 作为构建网络应用程序的语言非常受欢迎。正因为如此,存在许多工具和框架,Python 可以使用它们来创建网络应用程序,Flask 就是其中之一。还有 Django、Bottle、Pyramid、Turbogears、CherryPy 等更多。所有框架都很有用;有些比其他框架有更特定的用例,有些比其他框架更快,有些则更专门用于创建某些类型的网络应用程序和服务。

Flask 位于中间地带,因其小巧且初始学习曲线最小而受到欢迎。随着你的技能和需求的发展,许多与 Flask 集成的模块可供使用。这些模块将为 MyBlog 应用程序提供访问数据库、身份验证、授权和表单创建的能力。Flask 的美妙之处在于,你不必在需要时学习或使用这些扩展功能,直到你准备好使用它们来创建新功能。

提示:作为一名开发者,我使用过一些其他适用于 Python 的网络应用程序框架。Flask 是我使用最多且最熟悉的一个。我选择使用 Flask,因为我可以写关于它,知道我可以将 Flask 介绍给你,并且不会错过我如果选择一个我不太熟悉的框架可能会错过的细节。

6.4.2 你的第一个网络服务器

现在你已经知道你的目标在哪里了,让我们开始吧。第一个服务器直接来自 Flask 网站快速入门示例,是开始学习 Flask 的好地方。这是一个经典的 "Hello World" 示例程序,以网络应用程序的形式表达。服务器的代码存储在仓库中的 examples/CH_06/examples/01/app.py

from flask import Flask      ①

app = Flask(__name__)        ②

@app.route("/")              ③
def home():                  ④
    return "Hello World!"

① 将 Flask 系统导入到应用程序中

② 创建一个 Flask 实例对象,传递当前文件的名称

③ 此装饰器将主页函数连接到 "/" 应用程序路由。

④ 当用户导航到 "/" 路由时,将执行主页函数。

在运行本章示例的安装步骤并启动你的 Python 虚拟环境后,通过打开终端窗口并导航到 examples/CH_06/examples/01 目录来运行应用程序。对于 Mac 和 Linux,请输入以下命令:

export FLASK_ENV=development
export FLASK_APP=app.py

对于 Windows 用户,请输入以下命令:

set FLASK_ENV=development
set FLASK_APP=app.py

完成后,输入命令 flask run,应用程序将输出以下文本到终端窗口:

 * Serving Flask app "app.py" (lazy loading)
 * Environment: development
 * Debug mode: on
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 325-409-845

这些消息可能看起来很可怕,但它们只是通知您,Web 服务器正在开发模式下运行,这不适合生产环境。在这个上下文中,生产意味着运行 Web 服务器使其公开可用。Flask 内置的开发服务器没有优化或足够安全,不能在野外使用。

您还会注意到一条通知您,要停止应用程序,您需要按 CTRL-C 键。注意,终端光标不会返回,因为 flask run 命令正在无限循环中运行 app 实例,等待接收和处理请求。

服务器旨在长期运行,实际上除非被指示停止,否则永远不会停止。您刚刚启动的服务器处于空闲状态,等待接收和处理 HTTP 请求。Flask 开发服务器默认在 IP 地址 127.0.0.1 的 5000 端口上运行。

IP 地址 127.0.0.1 被称为 localhost,是您计算机网络接口的回环接口。这意味着即使您的计算机上没有安装网卡,您也可以在这个地址上创建服务器并访问它们。5000 的端口号是网络接口上 65,535 个可用端口中的一个未使用端口。这两个值都可以配置,但现在默认值就足够了。

要与服务器交互,您需要在计算机上打开一个网页浏览器,并将 http:/./127.0.0.1:5000 作为网址导航,然后点击回车。浏览器将响应,在内容窗口中打印出 "Hello World!"。您还会在运行服务器的终端窗口中看到一个日志消息,表明请求已被接收并正确处理,日志消息末尾的 200 表示 HTTP 状态码为“OK”,这意味着请求已成功处理。

连接路由

关于应用程序的一个重要注意事项是 Python 代码如何连接到一个有效的 URL 路由,服务器将对该路由做出响应。@app.route (")"/") 这行代码是 Flask app 实例提供的装饰器,应用于 home() Python 函数。装饰器是 home() 函数注册到 Flask 并连接到 URL "/" 路由的方式,当用户浏览到 http:127.0.0.1:5000 时将被调用。因为路由已被定义,服务器将对 home() 函数的运行结果做出响应,返回 "Hello World" 字符串。

永久服务

服务器启动后,它将继续响应请求,直到停止,本质上是在等待 HTTP 请求的过程中无限期地运行。应用程序代码中没有显式的循环,那么服务器是如何无限期运行的?这个循环是 Flask app 实例功能的一部分。当在终端命令行中调用 flask run 命令时,它会寻找名为 app 的对象,如果找到,则启动服务器事件循环。

事件循环是服务器等待事件处理的地方。事件是在端口 5000 上的网络套接字上显示的数据。对于 Web 服务器来说,事件是到达服务器监控的网络端口的 HTTP 请求。与你在应用程序代码中可能想到的无穷循环不同,服务器在等待事件时是空闲的,并且使用的 CPU 时间非常少。

未定义的路由

如果你回到浏览器并修改 URL 为http:/./127.0.0.1:5000/something并按回车键,浏览器将响应一个Not Found错误。查看终端窗口中的日志消息,你会看到一个消息表明请求已被接收,但服务器以 404 状态码响应。HTTP 协议状态码 404 基本上等同于页面未找到。

如果你查看你的 Web 服务器应用程序代码,这就有意义了。目前,唯一支持的 URL 是主页路由"/";没有定义来处理/something路由。服务器没有崩溃,因为它没有定义路由;相反,服务器将其处理为一个错误,并通过浏览器通知用户关于错误的信息。

处理错误并继续运行的能力是服务器设计和实现的重要部分。随着你开发 MyBlog 应用程序,你将使用 Flask 服务器处理并返回的错误来确定应用程序中存在的问题以及如何解决它们。

6.4.3 服务器内容

将你的第一个 Web 服务器编码并运行是一个大步骤。在app.py中的非常少的代码实现了和执行了大量的功能。home()函数展示了你可以如何将 URL 映射到 Web 服务器将支持的 Python 代码。你可以添加新的函数并将它们映射到额外的路由,Web 服务器将提供额外的 URL,浏览器可以导航到这些 URL。

要创建一个合适的网页,你可以用包含 HTML 代码的字符串替换home()函数返回的"Hello World!"字符串。通过这样做,浏览器将接收 HTML 并在浏览器窗口中渲染它。

然而,有用的和设计良好的网页是通过 HTML 代码创建的,这些代码可能有数百行,甚至数千行。直接将 HTML 代码嵌入到你的 Web 服务器中会使维护变得困难,并且没有充分利用 Flask 提供的功能。最好是将 HTML 内容和 Python 代码分开,并将 HTML 页面作为独立的文件构建,这是我们接下来要做的。

动态内容

通过 home() 函数提供给浏览器的内容是字符串 "Hello World!",每次访问或刷新页面时都会返回给浏览器。因为 home() 是一个 Python 函数,它可以返回任何内容,包括动态生成的信息和数据。该函数可以返回 random() 函数的结果,每次访问页面时浏览器都会渲染一个随机值。home() 函数可以返回计算结果、从数据库检索的数据或某些其他基于 HTTP 的网络服务的返回值。

创建并返回动态信息是创建有用网络应用的基础之一,MyBlog 项目就是其中之一。你是如何将动态信息与浏览器可以有意义渲染的 HTML 内容合并的呢?

Flask 包含了对名为 Jinja2 的模板语言的访问。可以将模板视为一个包含附加信息占位符的文档。这个模板将与数据结合,生成最终的完成文档。以下是一个使用 Python f-string 格式化来阐述这个概念的示例:

name = "Joe"
result = f"My name is {name}"
print(result)
My name is Joe

在这里,变量 name 被设置为字符串 "Joe",Python 格式化字符串 f"My name is {name}" 作为模板。创建并打印 result 变量,输出为 My name is Joe。Python 的 f-string 格式化就像一个小型模板语言;它接收 name 变量的数据形式并创建结果字符串输出。Jinja2 也与此类似。

提示:请记住,一旦服务器将内容发送到浏览器,它们之间就没有连接了。对于 MyBlog 应用程序,服务器生成的任何动态内容都必须在发送到浏览器之前注入到 HTML 内容中。

通过使用模板语言,你可以将 HTML 代码放在一个单独的模板文件中,然后让 Jinja2 将你的动态信息和数据替换到模板的正确位置。

使用模板语言

让我们修改之前的网络服务器代码,以使用 Jinja2 模板并将动态数据传递给模板以在浏览器窗口中渲染。以下修改后的代码位于 examples/CH_06/examples/02/app.py

from flask import Flask, render_template                       ①
from datetime import datetime                                  ②

app = Flask(__name__)

@app.route("/")
def home():
    return render_template("index.xhtml", now=datetime.now())   ③

① 导入 Flask 的 render_template 函数以使用 Jinja2

② 导入 datetime 功能以生成动态数据

③ 使用 render_template 函数将 index.xhtml 模板文件与 now 数据元素连接起来

render_template 函数的第一个参数是字符串 index.xhtml,这是包含 Jinja2 模板指令的模板文件的文件名。传递给 render_template 的其他所有内容都是命名参数。在示例中,命名参数是 now,它将被分配给 datetime.now() 返回的当前时间戳的值。

默认情况下,Flask 首先在名为 templates 的目录中搜索模板文件。模板目录应该与 app.py 文件位于同一目录中,所以现在创建它。

templates 目录中,你需要创建一个名为 index.xhtml 的文件。在示例应用程序中,index.xhtml 文件看起来像这样:

<!DOCTYPE html>                                                        ①
<html>                                                                 ①

<head>                                                                 ①
    <!-- Required meta tags -->                                        ①
    <meta charset="utf-8">                                             ①
    <meta                                                              ①
    ➥name="viewport"                                                  ①
    ➥content="width=device-width, initial-scale=1, shrink-to-fit=no"  ①
    ➥                                                                 ①
    <title>Your First Web Server</title>                               ①
</head>                                                                ①

<body>
    <h1>Current time: {{now}}</h1>                                     ②
</body>

</html>

① HTML 5 模板代码

② 在 {{now}} Jinja 输出表达式中插入当前日期和时间

这里展示的 HTML 5 代码是一个完整的网页,浏览器可以在其窗口中渲染。文件中有趣的是文档主体内的 <h1> 标题标签:

<h1>Current time: {{now}}</h1>

行中的 {{now}} 部分是 Jinja2 模板语法,将被 render_template 参数 now 的值替换,该值是当前的时间戳。

render_template 函数使用 Jinja2 模板引擎解析模板文件,并用数据替换遵循 Jinja2 语法规则的部分。Jinja2 还能够执行比简单替换更多的处理,我们很快就会看到。一旦创建了 index.xhtml 文件,目录结构应该看起来像这样:

├── app.py
└── templates
    └── index.xhtml

如果你运行 app.py 文件并导航到该 URL,浏览器将渲染图 6.4 所示的处理程序的输出。每次刷新页面时,时间戳都会更新。这表明 home() 函数正在运行,并且每次页面刷新时,index.xhtml 模板都会使用新的 datetime.now() 值进行渲染。

图片

图 6.4 您的第一个由 Flask 应用程序动态构建的网页

6.4.4 更多 Jinja2 功能

之前的例子是一个功能性的网络应用程序,但它只展示了 Jinja2 可以做到的一小部分。让我们扩展这个示例网络应用程序,以展示 Flask 和 Jinja2 的更多功能,这些功能你可以在 MyBlog 项目中使用。

更新的网络应用程序将有一个横幅和一个粘性页脚。粘性页脚是网页上的一种信息,即使它与页面上方的其余内容之间有空白,也会“粘”在页面的底部。

图 6.5 是一个截图,显示了当前时间和一个页面访问计数器,该计数器每次刷新页面时都会增加。还将有一个不同颜色的横幅列表和一个随机更改横幅背景颜色的按钮。应用程序提供额外的功能来提供随机颜色列表和页面访问计数器。服务器还以 CSS 文件的形式向浏览器提供样式信息,以及作为 JavaScript 文件的客户端(浏览器)交互性。

图片

图 6.5 带有更多样式、颜色和一些交互性的网页

页面访问计数器的功能由一个使用类级变量的 Python 类提供。通过使用类变量,页面计数器的状态可以通过类的任何实例访问。对变量的任何更改都会对使用它的任何实例可见。PageVisit 类具有简单的作用和接口:

class PageVisit:
    COUNT = 0

    def counts(self):
        PageVisit.COUNT += 1
        return PageVisit.COUNT

PageVisit 类维护类变量 COUNT 在所有类实例中的可用性。它还提供了一个 counts() 方法来增加 COUNT 的值并将其返回给调用者。每次调用 counts() 方法都会将 COUNT 的值增加一。PageVisit 类的存在是因为 Web 服务器可以同时处理许多用户,他们可能会对页面发起请求,并且必须在所有这些请求中维护 COUNT 的一个一致值。

提示:PageVisit 类适用于此用例,因为只有一个 Web 服务器正在运行。服务器处理的每个请求都会有一个 PageVisit 实例,但它们都会引用相同的 COUNT 类级变量。如果有多个 Web 服务器运行,则不会这样,因为 COUNT 类级变量将不再在多个 Web 服务器实例之间共享。

标签页背景颜色的列表被用于渲染页面 HTML 的模板文件和按钮点击时更改标签页背景颜色的 JavaScript 功能。因此,颜色列表必须对模板和运行在浏览器上的 JavaScript 引擎可用。

为了管理颜色列表,创建了 BannerColors 类。这个类将主要颜色列表封装为类级变量,并提供一个方法生成这些颜色作为列表的随机子集以供使用。像 PageVisit 类一样,BannerColors 类具有简单的作用和接口:

class BannerColors:
    COLORS = [
        "lightcoral", "salmon", "red", "firebrick", "pink",
        "gold", "yellow", "khaki", "darkkhaki", "violet",
        "blue", "purple", "indigo", "greenyellow", "lime",
        "green", "olive", "darkcyan", "aqua", "skyblue",
        "tan", "sienna", "gray", "silver"
    ]

    def get_colors(self):
        return sample(BannerColors.COLORS, 5)

BannerColors 类将类变量 COLORS 维护为有效 CSS 颜色名称字符串的列表。这创建了可以在页面上显示的标签页颜色调色板。get_colors() 方法使用 Random 模块和 Python 标准库中的 sample 函数返回五个颜色的随机子集作为列表。每次调用 get_colors() 时,它都会从 COLORS 类变量列表中返回一个颜色的随机子集作为列表。PageVisitBannerColors 类被添加到 examples/CH_06/examples/03 目录中的 app.py 文件,并集成到渲染网页的 home() 函数中:

@app.route("/")
def home():
    banner_colors = BannerColors().get_colors()    ①
    return render_template("index.xhtml", data={    ②
        "now": datetime.now(),                     ②
        "page_visit": PageVisit(),                 ②
        "banner_colors": {                         ②
            "display": banner_colors,              ②
            "js": json.dumps(banner_colors)        ②
        }                                          ②
    })

① 获取五个颜色的随机列表并将其分配给变量 banner_colors

② 创建一个信息字典,作为数据命名的变量传递给模板

BannerColors 类立即实例化,并调用 get_colors() 方法,将结果存储在变量 banner_colors 中。这个变量后来用于创建传递给模板进行渲染的数据。

使用render_template函数调用要渲染的模板名称index.xhtmldata变量。data变量是一个包含键/值对的字典,用于传递在模板中使用的信

  • nowdatetime.now()返回的值

  • page_visitPageVisit类的实例

  • banner_colors—另一个字典

  • display—之前创建的banner_colors列表变量

  • js—将banner_colors列表 JSON 序列化的结果

data字典内部的banner_colors字典包含banner_colors列表变量的两个变体。当你我们回顾更新的index.xhtml模板时,你会看到它是如何被使用的。

到目前为止的所有工作都是为了向用户浏览应用主页时运行的家操作添加功能。这个功能将新数据传递给index.xhtml模板,并由 Jinja2 渲染为完整的 HTML 页面。

模板继承

在我们回顾更新的index.xhtml模板之前,先看看之前在图 6.5 中展示的 Web 应用的截图。页面有一个横幅和页脚部分。这类视觉和信息特征通常在每个 Web 应用的页面上都是常见的。HTML 样板代码也是每个页面共有的。

根据“不要重复自己”(DRY)的原则,将 HTML 页面的公共元素集中起来,而不是将这些部分复制到每个页面中,这将非常有用。更糟糕的是,当 Web 应用发生变化和演变时,维护所有这些公共元素的副本。Jinja2 模板引擎通过模板继承提供这种功能,这在概念上类似于 Python 中的类继承。

在使用模板继承之前,你需要扩展 Web 应用的目录结构。因为你将要为应用提供静态 CSS 和 JavaScript 文件,这些文件需要存储在 Web 服务器可以访问的地方。默认情况下,Flask 在名为static的目录中查找静态文件,该目录是templates目录的兄弟目录。

在与templates目录同一级别的位置创建static目录。为了帮助保持static目录的有序性,创建CSSJS子目录,将 CSS 和 JavaScript 文件放入其中。这些文件将为 Web 应用提供其表现力和交互性。你的目录结构应该看起来像这样:

├── app.py
├── static
│   ├── css
│   └── js
└── templates
    └── index.xhtml

父模板

index.xhtml文件复制到templates目录下创建一个名为base.xhtml的新文件。这个文件现在是包含应用网页上所有常见特征的父模板。修改新的base.xhtml模板文件,使其看起来像这样:

<!DOCTYPE html>
<html>
<head>
                                            {% block head %} ①
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, 
    ➥initial-scale=1, shrink-to-fit=no">
    <title>Your Second Web Server</title>
    {% block styles %}                                       ②
    <link
    ➥    rel="stylesheet"
    ➥    type="text/css"
    ➥    href="{{ url_for('static', filename='css/myblog.css') }}"
    ➥ >                                                     ②
    {% endblock %}                                           ②
    {% endblock %}                                           ①
</head>

<body>
    <div id="header">
        <h1>MyBlog Home Page</h1>
    </div>
    <div id="content">
        {% block content %}{% endblock %}                    ③
    </div>
    <div id="footer">
        {% block footer %}                                   ④
        <h4>&copy; Copyright 2020 by MyBlog</h4>    
        {% endblock %}    
    </div>
</body>

{% block scripts %}{% endblock %}                            ⑤

</html>

① 创建一个名为 head 的模板部分,将被子模板引用

② 创建一个名为 styles 的内部模板部分,将被子模板引用以插入 CSS 文件引用

③ 创建一个名为 content 的空模板部分,它包含页面的内容,并由子模板提供

④ 创建一个名为 footer 的模板部分,子模板可以引用它

⑤ 创建一个名为 scripts 的空模板部分,子模板可以使用它来插入 JavaScript 文件引用

此模板包含您之前见过的 HTML 代码与 Jinja2 模板代码的混合。以 {% block head %} 开始并以 {% endblock %} 结束的模板代码创建了一个名为 head 的模板部分,子模板可以通过引用块名称来引用它。这样的块甚至可以从其他文件中引用。

命名为 styles 的块包含一个样式表链接。在链接的 href 部分中,还有一个 Jinja2 模板构造,{{url_for('static', filename='css/blog.css')}}。这个表达式替换正在执行 Python 的 url_for 函数。

在 Web 应用程序中硬编码 URL 路径通常不是一个好主意,而 url_for 函数有助于避免这种情况。通过传递已知的 URL 端点作为第一个参数和相对文件路径作为第二个参数,该函数可以创建一个对 Flask 应用程序有效的目标文件的 URL。当模板被渲染时,浏览器渲染的样式表中将存在一个指向 blog.css 文件的合法 URL。

分别名为 contentscripts 的空块部分创建引用,这些引用将由继承自 base.xhtml 模板的 index.xhtml 文件使用。index.xhtml 文件将使用这些引用将内容注入页面,并包含一个名为 index.js 的页面特定 JavaScript 文件,其中包含客户端交互代码。

子模板

现在您已经有了基础模板,是时候通过修改 index.xhtml 模板来继承它了:

{% extends "base.xhtml" %}                                 ①

{% block content %}                                       ②
<h2>Current time: {{ data["now"] }}</h2>                  ②
<p>Page visits: {{ data["page_visit"].counts() }}</p>     ②
<p>List of available banner colors:</p>                   ②
<ul>                                                      ②
    {%                                                    ②
➥      for banner_color in                               ②
➥      data["banner_colors"]["display"]                  ②
➥      %}                                                ②
    <li>{{ banner_color }}</li>                           ②
    {% endfor %}                                          ②
</ul>                                                     ②
<div id="color-change">                                   ②
    <button class="change-banner-color">                  ②
        Change Banner Color                               ②
    </button>                                             ②
</div>                                                    ②
{% endblock %}                                            ②

{% block styles %}                                        ③
{{ super() }}                                             ③
<link                                                     ③
    rel="stylesheet"                                      ③
➥      type="text/css"                                   ③
➥      href="{{ url_for(                                 ③
➥          'static', filename='css/index.css'            ③
➥          ) }}">                                        ③
{% endblock %}                                            ③

{% block scripts %}                                       ④
{{ super() }}                                             ④
<script>                                                  ④
    const banner_colors =                                 ④
➥      {{ data["banner_colors"]["js"]| safe }};          ④
</script>                                                 ④
<script                                                   ④
➥      src="{{ url_for(                                  ④
➥          'static', filename='js/index.js') }}"         ④
➥></script>                                              ④
{% endblock %}                                            ④

① 使此子模板继承自基础模板 base.xhtml

② 创建要渲染到页面上的内容,这将替换父模板中的空内容块

③ 为此子模板添加特定的 CSS 样式信息到样式块中。{{ super() }} 表达式首先调用父样式块,然后添加此块的内容。

④ 为此子模板添加特定的 JavaScript 文件引用到脚本块中。{{ super() }} 表达式首先调用父样式块,然后添加此块的内容。

与 Python 类一样,子模板通过在模板代码的第一行引用父模板来继承它。{% extends base.xhtml %} 模板代码通知 Jinja2 index.xhtml 正在继承 base.xhtml。模板引擎知道如何以与找到 index.xhtml 模板相同的方式找到 base.xhtml 模板,即通过在 templates 目录中查找。

index.xhtml文件提供的内容从{% block content %}开始标记开始,以{% endblock %}标记结束。当 Jinja2 渲染完整页面时,内容将被放置在base.xhtml父模板文件中内容块引用的位置。

通过render_template函数传递给模板的数据在内容部分内部使用。{{data["now"]}} Jinja2 表达式获取当前时间戳。{{data["page_visit"].counts()}}表达式获取PageVisit实例并调用其counts()方法以获取当前页面访问次数。

Jinja2 语言通过使用for循环提供了一种在渲染的模板中创建重复数据的机制。模仿 Python,循环结构是一个遍历可迭代对象data["banner_colors"]["display"]列表内容的For-In循环。列表中的每个项目都用于创建一个带有代码<li>{{banner_color}}</li>的 HTML 列表元素。for循环以{% endfor %}标记结束。

命名为styles的块引用了base.xhtml模板中的相同块。回想一下,父模板中的样式表块不为空;它有一个样式表链接来提取所有网页共有的展示信息。{{super()}}表达式在包含子index.xhtml模板中定义的信息之前渲染父样式表块。

命名为scripts的块处理几个函数。它使用{{super()}}表达式渲染由父模板定义的内容,目前为空。然后直接构建一些 JavaScript 代码来定义一个名为banner_colors的变量,该变量使用由{{data["banner_colors"]["js"] | safe}}提供的 JSON 格式字符串初始化。语法中的| safe部分防止 Jinja2 翻译可能危险的符号。在这里不是必需的,因为数据来自应用程序本身,但值得记住。

如果数据是通过用户表单提供的,数据可能包含可能成为 XSS 攻击的信息。XSS 攻击可以是插入到用户输入数据中的 JavaScript,这可能导致您的网站执行非预期操作。最后,包含一个引用外部 JavaScript 文件的脚本标签,使用相同的url_for()机制为网络应用程序创建一个有效的相对 URL。

展示

网页的展示由index.css文件控制,该文件包含应用于由 Flask 的render_template函数创建的 HTML 元素的 CSS 代码,这些元素由浏览器展示。应用程序连接了两个 CSS 文件——myblog.cssindex.cssmyblog.css文件应用于父模板文件base.xhtml

html, body {
    height: 100%;
}

body {
    display: flex;
    flex-direction: column;
    margin: 0px;
    font-family: Arial, Helvetica, sans-serif;
}

#header h1 {
    margin: 0px;
    background-color: darkcyan;
    height: 75px;
    text-align: center;
    line-height: 75px;
}

#content {
    flex: 1 0 auto;
    margin: 20px;
}

#content h2 {
    border: 3px solid lightgray;
    border-radius: 5px;
    padding: 20px;
    text-align: center;
    background-color: bisque;
}

#footer {
    flex-shrink: 0;
}

#footer h4 {
    margin: 0px;
    background-color: lightgrey;
    height: 50px;
    text-align: center;
    line-height: 50px;
}

虽然这本书不是关于 CSS 的,但回顾一些之前的代码有助于了解 CSS 代码如何影响网页的展示。请注意,CSS 代码的间距和缩进是可读性的约定,不是必需的语法的一部分。

CSS 代码是关于使用和创建选择器,将特定的样式信息附加到 HTML 元素上,以便浏览器可以按照预期的外观和感觉渲染 HTML 元素。例如,代码#content h2 {...}将样式规则附加到包含在<div id="content">元素中的 HTML <h2>元素上。此选择器缩小了样式将在页面上应用的范围;在这种情况下,只有<div id="content">标签内的<h2>标签将具有 20 像素的内边距和圆角边框。标题文本将居中,并具有bisque背景色。其余的选择器将样式规则应用于base.xhtml页面的其他部分。

这些样式将应用于继承自base.xhtml父模板的每个页面。index.css文件将规则应用于index.xhtml子模板页面:

#color-change button {
    background-color: lightgrey;
    border-radius: 5px;
    border: 1px solid grey;
    display: inline-block;
    cursor: pointer;
    color: black;
    font-family: Arial;
    font-size: 16px;
    font-weight: bold;
    padding: 13px 69px;
    text-decoration: none;
    text-shadow: 0px 0px 0px lightskyblue;
}

#color-change button:hover {
    background-color: darkgrey;
}

#color-change button:active {
    position: relative;
    top: 1px;
}

代码中的选择器应用于由index.xhtml页面创建的 HTML 元素,本质上为颜色变化按钮添加了一些样式和 CSS 交互性。

交互性

这本书不是关于 JavaScript 的,其使用将保持在最低限度,但大多数有趣的网络应用程序都将包含一些 JavaScript 代码。一旦服务器构建了 HTML 页面,它就会被作为响应发送到浏览器。然后浏览器将在浏览器窗口中可视地渲染 HTML。它还将解析和编译响应中发送的以及从外部文件中提取的 JavaScript:

window.addEventListener('load', function (event) {    ①
    let banner = document.querySelector(
➥           "#header h1"
➥      );                                            ②
    window.addEventListener(                          ③
➥          'click', function (event) {               ③
        // is this the click event                    ③
➥          we're looking for?                        ③
        if (event.target.matches(
➥             '.change-banner-color'
➥          )) {                                      ④
            let color = banner_colors[
➥                  Math.floor(
➥                       Math.random() * 
➥                       banner_colors.length)
➥                  ];                                ⑤
            banner.style.backgroundColor = color;     ⑥
        }
    })
});

① 在执行嵌套代码之前等待页面加载

② 获取横幅元素的引用

③ 为横幅颜色变化按钮添加点击事件处理器

④ 检查点击事件是否来自按钮

⑤ 从 banner_colors 列表中随机选择颜色

⑥ 更改横幅背景颜色

这是一段纯 JavaScript 代码(不涉及 jQuery 等框架),当显示的按钮被点击时,它会添加一个动作。代码创建了一个匿名函数,在页面加载完成后运行。匿名函数创建了对横幅元素的引用,然后添加另一个匿名函数来监听click事件。

click事件处理器内部,一个条件语句检查事件是否由更改横幅颜色的按钮生成。如果是这样,从banner_colors列表中随机选择一个颜色,并用于更改横幅的背景颜色。

6.5 运行网络服务器

更新后的应用程序位于examples/CH_06/examples/03

├── app.py
├── static
│   ├── css
│   │   ├── index.css
│   │   └── myblog.css
│   └── js
│       └── index.js
└── templates
    ├── base.xhtml
    └── index.xhtml

在终端中,移动到目录并设置环境变量FLASK_APP以指向您的应用程序,在 Mac 和 Linux 的命令行中输入以下内容:

export FLASK_ENV=development
export FLASK_APP=app.py

对于 Windows 用户来说:

set FLASK_ENV=development
set FLASK_APP=app.py

通过在终端命令行中输入flask run来运行 Web 服务器。你应该看到服务器启动,然后你可以导航到http:127.0.0.1:5000来查看应用程序。

当你使用flask run命令运行 Web 应用程序时,服务器会启动并使用 Flask 内置的 Web 服务器运行。内置的 Web 服务器适合开发和实验,但不适合生产。

对于生产环境,你需要使用一个生产就绪的 WSGI 服务器,这代表 Web 服务器网关接口。WSGI 服务器是一个提供简单调用约定以将请求从 Web 服务器转发到 Python 网络应用程序的应用程序。Flask 内置的 Web 服务器是一个 WSGI 服务器,它为开发目的提供这种调用约定。

WSGI 标准的存在是为了抽象出将你的 Python 网络应用程序与 Web 服务器和世界交互的复杂性。只要你的应用程序是按照 WSGI 接口标准构建的——Flask 和其他几乎所有 Python 网络框架都是这样做的——你的应用程序就可以通过互联网提供可访问的请求-响应处理。

两个最常见的生产级 WSGI 服务器是 uWSGI 和 Gunicorn。uWSGI 应用程序是一个用 C/C++编写的流行、高性能应用程序。Gunicorn,简称绿色独角兽,也是一个高性能的符合 WSGI 规范的 Web 服务器应用程序。两者都适用于生产环境。

6.5.1 Gunicorn

要使用 Gunicorn 运行你的应用程序,你需要使用以下命令从你的 Python 虚拟环境中安装它:

pip install gunicorn

在一个示例应用程序目录中,在终端中输入以下命令:

gunicorn -w 4 app:app

这告诉 Gunicorn 启动四个工作进程实例,它通过命令中的app:app部分找到你的应用程序。第一部分是 Python 文件名,app.py,第二部分:app指的是代码中通过app = Flask(__name__)部分在应用程序内部创建的 Flask 应用程序实例。

使用 Gunicorn 工作进程运行应用程序的多个实例可以让你的应用程序扩展到每秒处理数百甚至数千个请求。应用程序可以处理的每秒请求数量取决于每个请求对应用程序的工作负载以及生成响应所需的时间。

根据 Gunicorn 文档,在单个生产服务器上运行的应用程序推荐的工作进程数是(2 × CPU 核心数) + 1。这个公式大致基于这样一个想法:对于任何给定的 CPU 核心,一个工作进程将执行 IO(输入/输出)操作,而另一个工作进程将执行 CPU 操作。

6.5.2 商业托管

当你想让你的网络应用程序对公众可用时,你需要通过托管服务来实现。有许多服务可供托管你的应用程序。它们将提供诸如 Apache 或 Nginx 这样的选项用于 Web 服务,以及 uWSGI 和 Gunicorn 用于与基于 Python 的网络应用程序的 WSGI 接口。使用 Docker 容器部署你的应用程序也是可能的。

提示:在 Docker 容器内部,你会运行一个符合 WSGI 规范的 Web 服务器来与你的容器内 Python 应用程序进行接口。你将通过 Docker 容器的宿主连接到这个符合 WSGI 规范的 Web 服务器(uWSGI、Gunicorn 等)。

我确信这里列出的选项和配置比我所列出的要多。选择取决于你,你的应用程序目标以及这些选择的成本。

由于可用的选项范围广泛,以及这些选项提供的组合,我不会花费时间定义如何将基于 Flask 的 Python 应用程序部署到特定的示例。我的理由有两个:

  • 我不太可能找到一组完全适合你的部署用例的选择。

  • 部署应用程序是一个值得单独成书的话题,并且并不直接有助于成为一个扎实的 Python 开发者。

6.6 总结

下一章将开始为 MyBlog 应用程序打下基础,这个应用程序将在整本书中逐渐发展成为一个功能齐全的应用程序。在这个过程中,你将学习如何处理大型应用程序的开发以及如何将其与持久数据库集成。你到目前为止所学的内容可以使项目易于构建并令人愉快地实现。

你已经将你视野中的想法聚焦起来,现在可以看到前进的方向以及我们将要学习的一些内容。我们将沿着这条道路继续前进,在更仔细地观察它们时,我们将详细考虑开发项目。

总结

  • 互联网世界的很大一部分都是由于服务器,尤其是 Web 服务器而得以存在的。了解如何创建基于服务器的应用程序是一个扎实开发者的基石技能。

  • Flask 网络应用程序开发框架是许多此类框架中的一种,对于 Python 开发者来说非常适合作为教学框架来创建我们将在本书的其余章节中创建的 MyBlog 网络应用程序。

  • Flask 附带包含的 Jinja2 模板系统是一种创建具有常见元素和动态创建元素混合的网页的强大方式。模板中动态元素的内容可以来自任何来源——包括数据库、计算和其他服务器。你可以用 Python 程序访问的几乎所有内容都可以通过动态元素注入到模板中。

  • Jinja2 模板支持继承,这意味着一个包含整个网站中使用的通用元素的模板可以从页面特定的模板中继承。使用这种继承可以显著降低构建动态 Web 应用程序的工作量。

7 以风格行事

本章涵盖

  • 应用程序风格

  • 集成 Bootstrap 风格

  • 创建可扩展的 MyBlog

  • 使用蓝图命名空间

  • 应用程序配置

  • 集成 Flask 调试工具栏

  • 配置日志信息

创建网络应用程序需要整合许多概念和技术。为了创建引人入胜的应用程序,有必要考虑外观和感觉,或风格。对于网络应用程序,这主要是由应用于 HTML 内容的 CSS 风格提供的。

集成良好的风格实践提高了应用程序封装的复杂性。为了帮助维持不断增长复杂性,有必要考虑项目结构和命名空间的使用。项目结构和命名空间有助于项目以保持可管理复杂性的方式增长和扩展。本章为 MyBlog 应用程序奠定了基础,以便它可以以有助于您保持对应用程序目标清晰并领先于复杂性曲线的方式增长和演变。

7.1 应用程序风格

创建具有有趣和有用功能的网络应用程序对于保持用户在任何应用程序中的积极参与是必要的。功能集是必不可少的,但并非唯一需要吸引并保持用户注意力的因素。应用程序的外观是用户对现代计算机系统期望的关键因素。

看看任何流行的手机应用程序,你就会知道这是真的。最好的应用程序既有有用的功能集,又为用户提供引人入胜的视觉体验。即使应用程序具有吸引人的功能,如果应用程序看起来笨拙且未经打磨,也很难找到愿意接受和使用它们的用户。

浏览器的 CSS 风格代码控制网络应用程序的视觉外观,以确定如何将 HTML 代码渲染到屏幕上。第六章的第一个网络应用程序使用了简单、手工编写的 CSS 代码来应用独特的外观和感觉。继续为 MyBlog 应用程序手动编写 CSS 风格是可能的,但存在以下缺点:

  • 创建吸引人的 CSS 风格需要付出努力。

  • 随着应用程序的增长,在整个应用程序中保持风格一致性变得具有挑战性。

  • 在多个浏览器之间进行样式标准化以实现一致的渲染是棘手的。

  • 手机和平板电脑正成为应用程序与用户之间的主要接口;使网络应用程序对那些设备做出响应是至关重要的。

7.1.1 创建吸引人的风格

第六章的第一个网络应用程序只有一个页面,上面应用了相对简单的样式,由两个文件组成——myblog.cssindex.css——每个文件大约有一页的文本长。继续创建自定义样式会导致许多包含数百行代码的 CSS 文件。很快就会很明显,这需要额外的辛勤工作。

7.1.2 风格一致性

即使是具有中等复杂性的网络应用,也会与其关联多个页面,用户会在这些页面之间导航。确保按钮、列表、面板和其他视觉元素在所有页面上的外观一致,对于你在整个应用中试图描绘的统一画面来说非常重要。

即使在应用的不同用例中,给这些视觉元素相同的样式也是一项挑战。随着你的应用随着更多功能和页面的增加而扩展,保持这种一致样式会加剧这个挑战。

7.1.3 样式标准化

和我一样,你可能大多数时间都使用单个网络浏览器。即使你在家里和工作场所使用不同的浏览器,你也可能没有意识到样式一致性以及在不同浏览器间标准化样式的必要性。当你构建一个应用并生成没有任何样式应用到的 HTML 代码时,浏览器会使用其默认样式来渲染 HTML。

每个浏览器都会将其默认样式应用到标题、段落、字体以及元素之间的间距。如果你能在导航到你所熟悉的网络应用时抑制 CSS 样式,你就会看到页面是如何使用浏览器的默认样式进行渲染的。如果你要在多个操作系统上使用多个浏览器(如 Chrome、Firefox、Safari、Edge 等)做同样的事情,你就会看到这些浏览器之间渲染页面的不同——有时细微,有时显著。为任何你创建的网络应用在不同浏览器和操作系统上提供一致的外观意味着创建 CSS 代码来覆盖这些浏览器的默认样式。

7.1.4 响应式设计

我们已经讨论了使用网络浏览器作为平台提供应用功能的优势。其中的一项影响是网络浏览器无处不在——在手机、平板电脑、笔记本电脑和台式机上。它们甚至被集成到汽车和家用电器中。这些设备可以并且确实具有不同的展示能力,包括屏幕大小、屏幕分辨率、速度和可访问性。

由于互联网的广泛可用性,你的网络应用可以在任何可以访问其 URL 的设备上运行。尝试为所有这些设备进行样式设计是不可能的;它们的数量太多了。此外,还不断有新的设备推出,它们具有新的功能。

由于为这么多设备编码是不可能的,因此有必要使用响应式设计原则。响应式设计意味着使用流体、基于比例的网格;媒体查询;以及灵活的图像。使用这些工具可以创建一个设计布局,它会自动调整到设备的屏幕大小。Bootstrap 框架提供了创建使用响应式设计理念和实现网络应用所需的大部分功能。

7.2 集成 Bootstrap

在不提出解决方案的情况下,我不会展示在开发 MyBlog 网页应用程序中遇到的样式速度障碍。我选择的解决方案是 Twitter 创建的 Bootstrap CSS 框架(getbootstrap.com/)。Bootstrap 框架解决了之前提出的问题,让您免于解决许多样式问题,并让您专注于应用程序的设计和实现。

采用 Bootstrap 可以让您免于编写创建 MyBlog 展示所需的 CSS 样式代码。使用 Bootstrap 为 MyBlog 应用程序提供了一个吸引人、一致的用户界面;在浏览器和操作系统之间标准化该界面;并解决了许多响应式设计问题。使用 Bootstrap 意味着您仍然需要将 CSS 类名添加到 MyBlog 网页应用程序的 HTML 元素中,但所需的自定义 CSS 代码量减少是非常值得的。

使用 Bootstrap 对您的 Web 应用程序进行样式化还意味着您的应用程序将具有 Bootstrap 的“外观”。这种“外观”可能是有利的,因为视觉呈现是吸引人的、广为人知且易于理解的。选择使用 Bootstrap 是一个好的选择,因为这本书的重点是成为一个熟练的 Python 开发者,并具备一些网页设计技能。使用 Bootstrap 并不会阻止您完全自定义设计,如果您想探索自定义应用程序设计提供的可能性,它提供了一个出色的起点。

Bootstrap 版本

MyBlog 应用程序使用 Bootstrap 版本 5。版本 5 更注重现代浏览器,使 CSS 代码更具未来友好性,更易于实现,并且可能更快地渲染。

此版本还取消了任何对 jQuery JavaScript 库的依赖,而是直接使用 JavaScript。jQuery 没有任何问题;它是一个强大的库,用于访问和操作 HTML 元素。然而,自从其创建以来,浏览器对 JavaScript 的支持已经变得更加一致和强大,使得对 jQuery 的依赖成为一个选择而不是必需。

7.2.1 现在的先前列举的例子,加入了 Bootstrap

第六章的最后一个小节中使用的最后一个 Web 应用程序使用了手动编写的 CSS 进行样式化。图 7.1 在开始进行更改之前,对该页面进行了回顾。

图 7.1 在开始进行更改之前,查看第六章中的网页,图 6.5

您的初始目标是用 Bootstrap 提供的样式替换所有手动编写的 CSS 样式信息。这将是一个具有相同内容和功能的应用程序,但完全使用 Bootstrap CSS 样式类进行样式化。图 7.2 展示了即将进行的更改将创建的目标。

图 7.2 通过替换自定义 CSS 使用 Bootstrap 样式要达到的页面目标

在用 Bootstrap 替换手写 CSS 样式的第一步,你需要使 Bootstrap 框架对 MyBlog 网络应用程序可用。你将直接从 CDN(内容分发网络)访问文件,而不是下载 Bootstrap 框架并将必要的文件复制到你的 static/cssstatic/js 目录中。使用 CDN 访问 Bootstrap 可以让你不必复制本章和后续章节的文件。这也让 Flask 不必提供文件,因为 CDN 将处理这些文件。MyBlog 应用程序将使用 www.jsdelivr.com/ CDN,这是 Bootstrap 主页上推荐的分发机制。

Base.xhtml

你只更改了应用程序的样式,所以不需要更改 app.py 文件。因为 Bootstrap 将用于所有 MyBlog 页面,所以你将把它添加到 base.xhtml 模板中,使其对继承自它的任何模板都可用。你还需要更新模板文件中的手写样式信息,如下使用 Bootstrap 样式类:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>MyBlog</title>
  {% block styles %}
  <style>                                                 ①
    :root {                                               ①
        --background-url: url({{url_for("static",         ①
➥filename= "images/myblog_banner_50.png")}});            ①
    }                                                     ①
  </style>                                                ①
  <link                                                   ②
➥      rel="stylesheet"                                  ②
➥href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/     ②
➥dist/css/bootstrap.min.css" integrity="sha384-          ②
➥EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd            ②
➥3yD65VohhpuuCOmLASjC" crossorigin="anonymous">          ②
  <link                                                   ②
➥      rel="stylesheet"                                  ②
➥      href="https://cdn.jsdelivr.net/npm/               ②
➥          bootstrap-icons@1.8.1/font/                   ②
➥          bootstrap-icons.css"                          ②
➥      >                                                 ②
  <link 
➥      rel="stylesheet" 
➥      type="text/css" 
➥      href="{{ url_for(
➥          'static', 
➥          filename='css/base.css'
➥          ) }}"/
➥      >
  {% endblock styles %}
</head>
<body class="d-flex flex-column h-100">                   ③
  <div class="banner card">                               ③
    <div class="card-body">                               ③
      <div class="col-md-4 offset-md-1">                  ③
        <h2 class="card-title fw-bold">                   ③
➥            MyBlog Home Page                            ③
➥        </h2>                                           ③
        <p class="card-text">                             ③
➥            Just a blog to call my own                  ③
➥        </p>                                            ③
      </div>                                              ③
    </div>                                                ③
  </div>                                                  ③
  <main class="flex-shrink-0">                            ④
    {% block content %}{% endblock %}
  </main>
  <footer 
➥      class="footer fixed-bottom py-1 bg-light"
➥  >                                                     ⑤
    <div class="container text-center">
        <span class="text-muted">
➥            &copy; Copyright 2021 / MyBlog
➥        </span>
    </div>
  </footer>
  {% block scripts %}
  <script 
➥      src="{{ url_for(
➥          'static', 
➥          filename='js/bootstrap.bundle.min.js'
➥      ) }}">
➥  </script>                                             ⑥
  {% endblock %}
</body>
</html>

① 创建了引用横幅图片的 CSS 变量背景-URL,在这里应用是因为它使用 Jinja2 处理的模板中的 url_for()

② 从 CDN 包含 Bootstrap-minimized CSS 文件

③ 创建了横幅部分的内容及其样式

④ 创建了包含样式的内容部分,该样式由子模板提供

⑤ 创建了固定在页面底部的 Bootstrap 页脚

⑥ 包含了 Bootstrap-bundled,最小化的 JavaScript 代码

模板代码创建了 MyBlog 应用程序的内容和样式基础。继承自 base.xhtml 模板的每个模板都将具有这些内容和样式元素。base.xhtml 模板为整个应用程序的外观和感觉提供了一个基础,同时也简化了这项工作。

Base.css

base.xhtml 模板拥有自己的 base.css 文件,该文件覆盖了一些 Bootstrap 对横幅的样式。它还包含以下 CSS 媒体查询,使得应用在较小设备上以特定方式响应:

.banner.card {                            ①
    border: 0;                            ①
    border-radius: 0;                     ①
    background-clip: none;                ①
  }                                       ①

.banner {                                 ②
  display: none;                          ②
}                                         ②

@media (min-width: 768px) {               ③
  .banner {                               ③
    display: block;                       ③
    background: var(--background-url)     ③
➥    no-repeat center center / cover;    ③
  }                                       ③
}                                         ③

① 修改了 Bootstrap 卡片样式,移除了边框,将半径设置为 0,并移除了背景图像裁剪

② 将横幅的默认可见性设置为无,或不可见

③ 使用 CSS 媒体查询覆盖 .banner 设置,使其在屏幕尺寸大于 768 像素时可见

base.css 文件中有趣的部分是 .banner@media 部分。第一个设置了 .bannerdisplay 值为 none,防止它被渲染到显示中。@media 部分利用 CSS 的级联特性来影响横幅的显示方式。在 CSS 中,最后定义的样式会覆盖之前定义的任何样式。@media 部分在 CSS 中充当一个条件语句。如果屏幕尺寸大于 768 像素,则将显示值设置为 block,这意味着它将被渲染到显示中。背景部分定义了如何显示背景图像,并从 base.xhtml 模板中定义的 CSS 变量 background-url 获取该图像。

如果屏幕尺寸小于 768 像素,则初始的 .banner 定义 none 保持不变,横幅图像不会被渲染到显示中。@media 查询使 MyBlog 能够控制横幅图像的显示,为小屏幕设备提供更多显示空间来显示 MyBlog 内容。您将在查看 index.xhtml 文件更改后看到媒体查询如何影响显示的示例。

Index.xhtml

如前所述,index.xhtml 文件包含首页的内容。内容相同,但像 base.xhtml 文件一样,样式信息已更新为使用 Bootstrap。以下是更新的 index.xhtml 文件:

{% extends "base.xhtml" %}

{% block content %}                                                        ①
    <div class="container-fluid">                                          ①
        <div                                                               ①
➥            class="card bg-warning mb-3                                  ①
➥            font-weight-bold"                                            ①
➥            style="margin-top: 10px;"                                    ①
➥      >                                                                  ①
            <div class="card-body">                                        ①
                Current time: {{ data["now"] }}                            ①
            </div>                                                         ①
        </div>                                                             ①
        <p>                                                                ①
➥            Page visits: {{                                              ①
➥                data["page_visit"].counts()                              ①
➥            }}                                                           ①
➥        </p>                                                             ①
        <div class="card" style="width: 18rem;">                           ①
            <div class="card-header">                                      ①
                List of available banner colors                            ①
            </div>                                                         ①
            <ul                                                            ①
➥                class="list-group list-group-flush"                      ①
➥            >                                                            ①
                {% for banner_color in                                     ①
➥                   data["banner_colors"]                                 ①
➥         %}                                                              ①
                <li                                                        ①
➥                    class="list-group-item">                             ①
➥                        {{ banner_color }}                               ①
➥                    </li>                                                ①
                {% endfor %}                                               ①
            </ul>                                                          ①
        </div>                                                             ①
        <br />                                                             ①
        <button                                                            ①
➥            id="change-banner-color"                                     ①
➥            type="button"                                                ①
➥            class="btn btn-primary"                                      ①
➥        >                                                                ①
            Change Banner Color                                            ①
        </button>                                                          ①
    </div>                                                                 ①
{% endblock %}                                                             ①

{% block scripts %}
    {{ super() }}
    <script>
        const banner_colors = {{ data["banner_colors"] | tojson | safe }};
    </script>                                                              ②
    <script src="{{ url_for('static', filename='js/index.js') }}"></script>
{% endblock %}

① 创建了使用 Bootstrap 响应式容器和卡片样式的首页内容

② 将模板参数数据 data["banner_colors"] 转换为 JSON,以便页面 JavaScript 可以使用

首页内容的变化都是关于内容呈现的样式,而不是内容本身。当更改完成后,目录结构应该看起来像 examples/CH_07/examples/01

.
├── app.py
├── static
│   ├── css
│   │   └── base.css 
│   ├── images
│   │   ├── myblog_banner.png
│   │   └── myblog_banner_50.png
│   └── js
│       └── index.js
└── templates
    ├── base.xhtml
    └── index.xhtml

从该目录中,在 Mac 或 Linux 终端中执行以下命令:

export FLASK_ENV=development
export FLASK_APP=app.py
flask run

或者,对于 Windows 系统的终端,如下所示:

set FLASK_ENV=development
set FLASK_APP=app.py
flask run

MyBlog 网络服务器将运行,您可以使用浏览器导航到 127.0.0.1:5000 并查看应用程序。

应用程序显示了包含显示顶部图像的修改后的横幅。它还显示了页面底部的 Bootstrap 粘性页脚。这些元素来自 base.xhtml 模板,并将存在于继承自它的每一页上。

当前时间戳显示在一个 Bootstrap 卡片中,可用的颜色列表包含在另一个卡片中。此外,更改横幅背景颜色的按钮被样式化为 Bootstrap 按钮。

当浏览器屏幕大小大于 768 像素时,将渲染显示横幅,这很可能适用于台式机或笔记本电脑。这就是图 7.3 中展示的内容。如果你调整浏览器窗口使其变窄,最终你会越过 768 像素的边界,Web 应用显示将发生变化。图 7.4 展示了更新后的页面。横幅文本和图像的缺失展示了 base.css 文件中 @media 查询所隐含的条件。当条件变为假时,.banner CSS 类的初始定义变为活动状态,将显示值设置为 none。使用媒体查询为平板电脑和手机等小型设备提供了更多的垂直屏幕空间。

图片

图 7.3 添加 Bootstrap 样式后的页面显示

图片

图 7.4 改变浏览器大小激活媒体查询以移除横幅。

7.3 帮助 MyBlog 成长

在这个开发阶段,MyBlog 应用已经扩展了 Flask 文档中常见的 Flask 示例应用的基本功能。我们所添加的一切都是通过添加功能、样式以及使用 Jinja2 模板来扩展这个基本示例所能做到的。继续扩展我们迄今为止编写的代码的功能是可能的,但这样做会阻碍开发一个功能齐全且可扩展的应用。

所有新的功能都必须包含在 app.py 文件中,作为装饰有 @app.route(...) 的函数长列表,以将此功能连接到 Flask 应用。这样做打破了单一责任的概念,并使得为 Web 应用 URL 端点函数处理程序命名变得尴尬。

在单个大文件中工作,涉及许多技术领域的精神需求将会更加困难。app.py 文件将包含一个全功能博客应用所需的所有部分——认证、授权、数据库访问、用户管理以及创建和展示博客内容。

你已经看到了如何通过使用模块创建命名空间和命名空间容器来按逻辑或复杂边界拆分功能。在 MyBlog 应用中,我们将采用相同的方法。然而,在前进的过程中,需要注意一点,那就是 Flask 应用实例。

7.3.1 Flask 应用实例

在当前版本的 app.py 程序文件中,直接导入了 Flask 模块并创建了 Flask 应用实例变量 app。在根应用文件中创建 Flask 应用实例对于快速构建示例 Web 服务器的工作示例应用来说是可行的。

为什么当您想使用模块在 Web 应用程序中命名空间功能时,这种结构成为问题?因为您想通过具有hello_world()等 URL 端点功能的 Web 应用程序添加的任何功能或功能都需要访问 app 实例变量。

在当前 MyBlog 应用设置中,创建包含功能和功能的模块变得困难,因为这些模块将需要访问app实例。当前结构如图 7.5 所示。这提出了一个问题。app.py代码可以导入模块以访问附加功能,但那些相同的模块也需要访问app实例。如果这些模块从app.py导入app实例以获取访问权限,则会创建 Python 不允许的循环引用问题。向 MyBlog 当前实现添加功能和功能会导致难以向上扩展的结构。

图片

图 7.5 应用代码结构,其中功能包含在app.py文件中

解决应用实例问题

要解决模块访问 Flask app实例的问题,您需要更改应用的结构。Flask app实例是围绕其旋转的 Flask 应用功能的中心枢纽。除了迄今为止看到的@app.route(...)功能外,更多功能和特性将需要访问app实例,如后续章节所述。

由于app实例的核心作用,当需要访问它时,您将遵循两步过程来解决问题:将大部分应用代码放入 Python 包中,并创建一个工厂函数来实例化 Flask 的app实例。

在前面的章节中介绍了包的使用和创建,并用于创建模块命名空间。作为一个复习,创建一个包意味着创建一个目录并在目录中添加一个__init__.py文件。此文件的存在允许 Python 从包中导入模块。通过在包中创建包来创建有意义的命名空间层次结构,可以继续添加包,直到合理的深度。

在前面的章节中,__init__.py文件只需存在即可使目录成为 Python 包。从包中导入模块的活动的一部分也包括执行__init__.py文件中的任何 Python 代码。许多包中的__init__.py文件不包含代码,但可以向其中添加 Python 代码。

包中的任何模块都可以自动访问包的__init__.py文件中的代码和变量,而__init__.py文件可以访问包的兄弟模块。当创建用于创建 Flask app实例的应用程序工厂函数时,包将非常有用。

MyBlog 重构

你现在正处于一个很好的位置来重构 MyBlog 应用程序的文件布局,以创建一个有意义的层次结构。有一个有意的文件结构有助于使用与项目相关的和有用的目录结构中的文件。

首先要做的是将app.py文件重命名为myblog.py。然后创建一个名为app的目录,这是 MyBlog 应用的根包目录。将statictemplates目录移动到app目录中。

app目录内创建一个__init__.py文件,这将app目录转换为 Python 包。目录结构现在应该看起来像这样:

├── app
│   ├── __init__.py
│   ├── static
│   │   ├── css
│   │   │   └── base.css
│   │   ├── images
│   │   │   ├── myblog_banner.png
│   │   │   └── myblog_banner_50.png
│   │   └── js
│   │       └── index.js
│   └── templates
│       ├── base.xhtml
│       └── index.xhtml
└── myblog.py

应用程序工厂

重命名的myblog.py文件直接创建 Flask app 实例,然后使用它将 URL 端点连接到功能。我们的目标是获得更多控制创建app实例的能力,并使其与外部模块的使用更加容易。为此,你将在应用包的__init__.py文件中实现一个名为create_app()的应用工厂函数。编辑app/__init__.py文件并添加以下代码:

from flask import Flask, render_template
from datetime import datetime
from random import sample

class PageVisit:                   ①
    COUNT = 0                      ①
    def counts(self):              ①
        PageVisit.COUNT += 1       ①
        return PageVisit.COUNT     ①

class BannerColors:                ①
    COLORS = [
        "lightcoral", "salmon", "red", "firebrick", "pink",
        "gold", "yellow", "khaki", "darkkhaki", "violet",
        "blue", "purple", "indigo", "greenyellow", "lime",
        "green", "olive", "darkcyan", "aqua", "skyblue",
        "tan", "sienna", "gray", "silver"
    ]
    def get_colors(self):
        return sample(BannerColors.COLORS, 5)

def create_app():
    app = Flask(__name__)          ②

    with app.app_context():        ③

        @app.route("/")
        def home():
            return render_template("index.xhtml", data={
                "now": datetime.now(),
                "page_visit": PageVisit(),
                "banner_colors": BannerColors().get_colors()
            })

        return app                 ④

① 被home()函数使用的支持类

② 在应用程序工厂函数create_app()内部创建 Flask 应用实例

③ 开始上下文管理器以初始化应用程序的其余部分

④ 将应用实例返回给调用者

这段代码基本上是将myblog.py文件中的所有内容放入应用包的__init__.py文件中。因为这个文件几乎复制了myblog.py中的所有代码,所以这个文件需要更新为以下内容:

from app import create_app

app = create_app()

现在的myblog.py文件所做的一切就是导入应用工厂create_app,然后调用它来创建 Flask app 实例。将你的工作目录更改为examples/CH_07/examples/02,并在 Mac 或 Linux 终端中执行以下命令来运行应用程序:

export FLASK_ENV=development
export FLASK_APP=app.py
flask run

或者对于 Windows 系统终端:

set FLASK_ENV=development
set FLASK_APP=app.py
flask run

在浏览器中导航到127.0.0.1:5000将显示与之前相同的应用程序视图,但使用新的 MyBlog 应用程序结构。图 7.6 显示了应用程序页面及其功能。

下一步是将home()函数移动到外部模块,以便可以添加其他功能。为此,你将利用 Flask 蓝图功能。

图片

图 7.6 重新结构化的应用程序渲染了之前的页面。

7.4 命名空间

任何有趣的应用程序都将有许多功能,这些功能需要这些功能之间的交互,这增加了复杂性。MyBlog 网络应用程序也不例外,它将获得专注于特定应用程序部分的功能。与其将所有其他功能区域的代码滚入一个大的 Python 文件中,不如将工作分解成模块,这样你就可以一次在一个功能域中工作。

就像你在前面的章节中看到的那样,Python 包和模块包含高级命名空间,同样的想法将在这里应用。在 Web 应用程序中,这种关注点的分离包括为提供功能并将其放置在命名空间中的 URL 端点创建模块。Flask 框架通过 Blueprints 实现命名空间。

7.4.1 Flask 蓝图

在 MyBlog 应用程序中,当@app.route("/")装饰器应用于一个函数时,该函数会被服务器注册,以便在浏览器访问 URL 端点路径"/"时被调用。正如装饰器名称所暗示的,传入的"/" URL 路径参数是一个正在定义的应用程序路由。

@app.route装饰器允许你将 URL 路由连接到 Flask 应用程序,以便它可以处理由装饰过的函数处理的该路由的请求。所有 URL 路由都可以这样处理,但在更大的应用程序中,这会变得难以管理。Flask 网络框架提供了一个名为 Blueprints 的功能,用于实现服务 URL 端点或路由的独立模块。

Flask Blueprints 允许你将特定功能分离到模块中。当创建逻辑上独立的特性,如认证、授权和 Web 应用程序的其它部分时,蓝图非常有用。在第八章中,你将向 MyBlog 应用程序添加用户认证。认证保护了 MyBlog 应用程序提供给用户的几乎所有页面和功能的访问。

获取认证并使其在一个地方工作是有价值的,但一旦完成,这个功能可以轻松地用于完全不同的网络应用程序。你可以通过将蓝图放置在所有应用程序项目共享的库中,使你创建的几乎所有功能都对其他项目可用。你还可以将其放入像 GitHub 这样的仓库中,这样一组开发者就可以访问它。

7.4.2 将蓝图添加到 MyBlog

MyBlog 应用程序在应用程序包中的create_app()工厂函数内创建 app 实例。主页也包含在create_app()函数中。在本章稍后,你将向应用程序添加一个关于页面。让我们为主页和未来的关于页面创建一个命名空间,它们都可以在其中存在。我们将该命名空间称为intro,因为主页和关于页面将是 MyBlog 应用程序的介绍性内容。

要做到这一点,你将在应用程序包内创建一个 intro 包。作为第一步,在app目录内创建一个intro子目录。在那个子目录中,创建一个空的__init__.py文件,这使得intro目录成为一个 Python 包。

内容

在创建和使用蓝图时存在一定的鸡生蛋、蛋生鸡的问题,但让我们先从intro命名空间的功能开始。在intro子目录下,创建一个intro.py文件,内容如下:

from flask import render_template
from datetime import datetime
from random import sample
from . import intro_bp                        ①

class PageVisit:
    COUNT = 0

    def counts(self):
        PageVisit.COUNT += 1
        return PageVisit.COUNT

class BannerColors:
    COLORS = [
        "lightcoral", "salmon", "red", "firebrick", "pink",
        "gold", "yellow", "khaki", "darkkhaki", "violet",
        "blue", "purple", "indigo", "greenyellow", "lime",
        "green", "olive", "darkcyan", "aqua", "skyblue",
        "tan", "sienna", "gray", "silver"
    ]

    def get_colors(self):
        return sample(BannerColors.COLORS, 5)

@intro_bp.route("/")                          ②
def home():
    return render_template("index.xhtml", data={
        "now": datetime.now(),
        "page_visit": PageVisit(),
        "banner_colors": BannerColors().get_colors()
    })

① 导入尚未定义的 intro Blueprint 实例 intro_bp

② 注意到主页函数是用 intro_bp Blueprint 实例的路由函数装饰的,而不是 @app.route

intro 包

intro.py 文件包含 Blueprint 功能,并使用尚未定义的 intro_bp Blueprint 实例。intro_bp 实例的命名是一种约定;在实例名称末尾添加 _bp 表示 Flask Blueprint。下一步是编辑空的 app/intro/__init__.py 文件并创建 intro_bp 实例:

from flask import Blueprint                  ①

intro_bp = Blueprint('intro_bp', __name__,   ②
    static_folder="static",                  ②
    static_url_path="/intro/static",         ②
    template_folder="templates"              ②
)                                            ②

from app.intro import intro                  ③

① 从 flask 模块导入 Blueprint 类

② 创建一个 Blueprint 实例,初始化其名称、文件名以及静态和模板文件的路径

③ 导入 intro 模块的功能

因为 __init__.py 文件位于一个包中,所以每次导入包内的任何内容时,包括刚刚创建的 intro.py 模块,都会运行此代码。代码首先执行的操作是从 flask 模块导入 Blueprint 类。然后通过使用一些参数实例化 Blueprint 类来创建 intro_bp 实例。

参数赋予 Blueprint 一个名称,传递 Python 文件名,并使用与 Blueprint 位置相关的路径字符串设置 static_foldertemplate_folder 参数。这意味着 intro_bp Blueprint 实例期望在相对于包含 intro_bp Blueprint 定义文件的文件结构中的路径上找到它将渲染的模板以及这些模板可能需要的静态资源。

static_url_path 参数被设置为确保 Blueprint 相对路径不与根 static 文件夹冲突。分配给参数的值是从根目录到 Blueprint static 目录的相对路径。

这意味着提供主页内容的 index.xhtml 模板需要移动到 intro_bp Blueprint 可以找到的地方。这也意味着静态资源(CSS 文件、JavaScript 和图片)也需要移动。

更新后的 MyBlog 目录

将目录重构以放置所有与 intro Blueprint 相关的文件的结果位于代码仓库的 examples/CH_07/examples/03 目录中:

    ├── app
    │   ├── __init__.py
    │   ├── intro
    │   │   ├── __init__.py
    │   │   ├── intro.py
    │   │   ├── static
    │   │   │   ├── css
    │   │   │   │   └── index.css
    │   │   │   ├── images
    │   │   │   │   ├── myblog_banner.png
    │   │   │   │   └── myblog_banner_50.png
    │   │   │   └── js
    │   │   │       └── index.js
    │   │   └── templates
    │   │       └── index.xhtml
    │   ├── static
    │   │   ├── css
    │   │   │   └── base.css
    │   │   ├── images
    │   │   │   ├── myblog_banner.png
    │   │   │   └── myblog_banner_50.png
    │   │   └── js
    │   └── templates
    │       └── base.xhtml
    └── myblog.py

在这个目录结构中,存在两对 templatesstatic 目录,第一对是来自上一个示例的项目根目录中的原始目录,第二对是在 intro 包下设置的。第二对是 intro_bp Blueprint 实例在查找 templatesstatic 文件时将使用的,因为实例创建时传递给 static_foldertemplate_folder 参数的值。

如果这两个参数没有被设置,intro_bp实例将会在app目录下的statictemplates目录中寻找templatesstatic资源。在创建的蓝图(Blueprint)包下拥有templatesstatic文件夹使得蓝图更加自包含,并且可以方便地移植到其他项目中。

需要对index.xhtml文件进行一项额外的修改,以便它可以访问由 HTML 代码引用的静态资源。需要按照以下方式更新引用主页面上按钮点击响应的 JavaScript 的<script>...</script>标签:

<script src="{{ url_for('.static', filename='js/index.js') }}"></script>

唯一的改变是在url_for(...)语句中static前添加了一个单字符(.)。url_for(...)语句将解析此路径为相对于intro_bp蓝图(Blueprint)的static目录,并且index.js文件将被正确地拉取。

应用程序包更改

在你继续创建新的关于页面之前,先查看一下app/__init__.py文件。所有与主页相关的代码都已经移动到了intro模块中,因此该文件需要按照以下方式更新:

from flask import Flask

def create_app():
    app = Flask(__name__)
    with app.app_context():
        from . import intro                       ①
        app.register_blueprint(intro.intro_bp)    ②
        return app

① 导入包含 intro 蓝图的 intro 模块

② 注册 intro 蓝图到应用中

这段代码展示了简化后的应用程序工厂create_app()函数,移除了现在作为intro蓝图一部分的主页代码。在 MyBlog 应用程序中,当调用flask run命令时,只需要这些内容。默认情况下,flask run命令会在应用程序(在FLASK_ENV环境变量中)中查找名为app的 Flask 实例。找到app实例后,将启动应用程序,为已配置并注册到应用程序的任何 URL 端点提供服务。

7.4.3 创建关于页面

为了展示intro_bp蓝图实例可以包含功能和功能,你将在 MyBlog 应用程序中添加一个关于页面。为此,在intro.py文件中添加一个新的处理函数,并通过以下方式使用路由装饰它进行注册:

@intro_bp.route("/about")                  ①
def about():
    return render_template("about.xhtml")   ②

① 装饰 about()函数并将其注册为新的路由"/about"的处理程序,使用 intro_bp 蓝图实例

② 从 intro_bp 相关的模板目录检索并渲染 about.xhtml 模板文件

这段代码创建了一个指向"/about"的 URL 端点路由,并在浏览器导航到该路由时注册about()函数作为处理程序。为了渲染关于 HTML 页面,在app/intro/templates目录中创建了一个about.xhtml模板,该目录相对于intro_bp蓝图:

{% extends "base.xhtml" %}                ①

{% block content %}                      ②
    <div class="container-fluid mt-3">
        <div class="card">
            <div class="card-header">
                About
            </div>
            <div class="card-body">
                <h5 class="card-title">Information about this website</h5>
                <p class="card-text">
                    This is an implementation of the MyBlog blogging
                    web application. This web application is developed over
                    the course of multiple chapters from the book, 
                    "The Well-Grounded Python Developer". 
                </p>
                <p class=”card-text”>
                    The intent of the MyBlog application is not to create
                    a complete and fully feature blogging system, there are
                    many of those in existence already. The goal is to
                    progressively see and learn how to implement the big
                    picture that's necessary to implement a complex system
                    like this.
                </p>
            </div>
        </div>
    </div>
{% endblock %}

① 与 index.xhtml 类似,关于页面继承自 base.xhtml,并获得了它提供相同的特性。

② 关于页面用 Bootstrap 风格的文本信息替换了内容块,关于 MyBlog 应用程序。

如果你运行 MyBlog 应用程序并在浏览器中导航到127.0.0.1:5000/about,服务器将通过渲染about.xhtml文件到显示窗口来响应。图 7.7 是关于页面的截图。关于页面使用 Bootstrap 样式的方式与主页相同。

图片

图 7.7 使用 Bootstrap 样式渲染的新关于页面

7.4.4 重构应用实例

到目前为止,MyBlog 应用程序(特别是 Flask app实例)已经被重构,以更好地支持不断增长的 Web 应用程序。图 7.8 显示了create_app()函数如何连接到外部intro模块中包含的功能,该模块在该函数的作用域内可以访问app实例。app实例由在myblog.py代码作用域内导入的create_app函数返回。app实例的引用由myblog.py在整个应用程序的生命周期中持有。

图片

图 7.8 重构后的 My**Blog 应用程序的视觉结构

7.5 导航

MyBlog 应用程序现在有两个页面——主页和关于页面。你可以通过在浏览器中输入 URL 直接导航到它们,但这不是很方便。网站提供可点击的链接来在应用程序中导航;你将通过使用 Bootstrap 来添加这个导航。

MyBlog 网站的导航是通过添加到base.xhtml模板中的 Bootstrap 导航栏来提供的,因此它会在继承自它的任何页面上渲染,本质上是在整个网站上。Bootstrap 导航栏视觉上吸引人,并能根据设备大小做出响应。在小设备上它会收缩成一个下拉菜单。

你还可以通过使当前活动页面的菜单项明显突出显示来添加一些交互式效果。这意味着如果正在查看关于页面,关于菜单项将被突出显示。你将通过利用 Jinja2 中的功能来实现这一点。

7.5.1 创建导航信息

添加导航栏并使其交互式有两个部分。首先要做的事情是编辑根模板文件夹中的base.xhtml模板文件,并在文件顶部添加以下代码:

{# configure the navigation items to build in the navbar #}  ①
{% set                                                       ②
  nav_items = [                                              ②
    {"name": "Home", "link": "intro_bp.home"},               ②
    {"name": "About","link": "intro_bp.about"}               ②
  ]                                                          ②
%}                                                           ②

① 这是在模板中的 Jinja2 注释。

② 创建一个名为 nav_items 的变量,包含一个字典列表,每个字典项中都有导航信息

{% set ... %}块允许你创建一个变量,就像在模板的其他部分使用的 Python 代码中一样。nav_items列表变量持有构建 Bootstrap 导航栏链接所需的导航信息。

查看 nav_items 结构中的链接信息。主页链接是"intro_bp.home",而不是仅仅home。这将在模板中的 HTML 链接中显示,如下所示:

<a href="{{url_for('intro_bp.home')}}">Home</a>

url_for函数知道如何找到相对于intro Blueprint 的页面,并使用intro Blueprint 实例intro_bp。然后,它找到相对于该实例的home URL 端点。你将在渲染导航栏时看到这一点是如何使用的。

7.5.2 显示导航信息

创建导航栏的第二部分位于base.xhtml模板文件的下方。在{% block content %}{% endblock %}上方插入以下代码:

<nav class="navbar navbar-expand-lg 
➥navbar-dark bg-primary">                          ①
  <a 
➥      class="navbar-brand ml-2" 
➥      href="{{url_for('intro_bp.home')}}"
➥  >                                               ②
    MyBlog                                          ②
  </a>                                              ②
  <button class="navbar-toggler" 
          type="button" 
          data-bs-toggle="collapse" 
          data-bs-target="#navbarSupportedContent" 
          aria-controls="navbarSupportedContent" 
          aria-expanded="false" 
          aria-label="Toggle navigation">
    <span class="navbar-toggler-icon"></span>
  </button>
  <div class="collapse navbar-collapse" 
       id="navbarSupportedContent">
    <div class="navbar-nav">
      {% for nav_item in nav_items %}               ③
        {% if request.endpoint == 
➥           nav_item["link"] %}                    ④
          <a class="nav-link ml-2 active"           ⑤
             aria-current="page"                    ⑤
             href="{{url_for(                       ⑤
➥                nav_item['link']                  ⑤
➥             )}}"                                 ⑤
➥          >                                       ⑤
              {{nav_item["name"]}}                  ⑤
          </a>                                      ⑤
        {% else %}
          <a class="nav-link ml-2"                  ⑥
             href="{{url_for(                       ⑥
➥                 nav_item['link']                 ⑥
             )}}"                                   ⑥
➥          >                                       ⑥
              {{nav_item["name"]}}                  ⑥
          </a>                                      ⑥
        {% endif %}
      {% endfor %}
      </div>
  </div>
</nav>

① 这开始 Bootstrap 导航栏样式部分,并设置导航栏的颜色和样式。

② 创建 MyBlog 品牌图标作为可点击链接到主页

③ 遍历 nav_items 变量

④ 比较当前页面与当前 nav_item 链接

⑤ 如果比较为真,则输出高亮链接

⑥ 如果比较为假,则输出普通链接

这里有很多事情在进行中。大部分代码都是关于在正确的上下文中将 Bootstrap 样式的类放在正确的位置。当使用 Bootstrap 时,这是一些大量的样式信息需要处理和学习。然而,与使用手写的 CSS 代码提供相同的样式功能相比,这微不足道。

模板代码中的一个有趣部分是for循环和其中的if语句。for循环遍历之前创建的nav_items列表,每次拉出一个nav_itemif语句使用内置对象和属性request.endpoint来确定当前构建的页面是否与nav_item["link"]值相同。

如果当前页面等于nav_item链接,则导航栏菜单项是一个包含类active和 HTML 属性aria-current= "page"的 HTML 链接。Bootstrap 的active类为菜单项添加了视觉突出显示。HTML 属性aria-current="page"帮助使用屏幕阅读器的用户导航到页面,以实现无障碍访问。如果当前页面不等于nav_item链接,则导航栏菜单项以默认状态渲染,没有应用突出显示。

7.5.3 MyBlog 的当前外观

如果你将工作目录更改为examples/CH_07/examples/04,那里有一个完全功能性的示例程序,实现了我们所讨论的内容。使用以下命令为 Mac 和 Linux 用户运行应用程序:

export FLASK_ENV=development
export FLASK_APP=myblog.py
flask run

以下是 Windows 用户的命令:

set FLASK_ENV=development
set FLASK_APP=myblog.py
flask run

当应用程序运行时,使用你的浏览器导航到127.0.0.1:5000,你会看到应用程序。

图 7.9 显示了带有新添加的 Bootstrap 导航栏的当前 MyBlog 应用程序,位于横幅下方。这是展开视图,主页菜单项通过之前显示的base.xhtml模板代码突出显示。

图 7.9 MyBlog 应用程序显示 Bootstrap 导航栏

如果你开始水平缩小浏览器窗口,你会看到导航栏导航将减少到导航栏右侧的下拉按钮(图 7.10)。点击该按钮,导航菜单项将出现,允许访问主页和关于链接。这显示了使用 Bootstrap 响应式设计功能的优点。

图 7.10 Bootstrap 有其自己的媒体查询来管理较小的屏幕,调整菜单栏。

7.6 应用程序配置

自从 MyBlog 应用程序首次发布以来,它已经取得了长足的进步,并且还有工作要做以进一步改进它。你即将开始的开发工作旨在使应用程序的配置、可持续性和可维护性更好、更简单。

7.6.1 配置文件

MyBlog 应用程序最终将依赖于需要配置数据的功能——向电子邮件服务发送电子邮件、访问数据库以存储用户和博客文章,以及为 Flask 应用程序本身提供安全数据。除了 MyBlog 功能集所需的配置数据之外,你还将想要为开发和生产环境设置不同的数据。

配置数据创建了一个环境,MyBlog 应用程序将在其中运行。开发环境通常提供额外的调试服务和对应用程序内部运作的访问。如果你在一个团队中开发应用程序,需要不同的配置数据来创建一个预发布环境。预发布环境是团队可以在将应用程序推入生产之前测试应用程序各个部分的地方。生产环境会移除调试信息,并限制只对打算公开提供的功能集进行访问。

7.6.2 私人信息

任何包含访问需要用户名、密码或 API 密钥的系统或服务的功能集的应用程序都需要保护这些信息不被泄露。例如,MyBlog 应用程序会自动向用户和管理员发送电子邮件。

MyBlog 应用程序不会直接处理发送电子邮件,而是会使用外部的 SMTP 电子邮件服务器。应用程序需要使用用户名和密码以及可能是一个 API 密钥来验证电子邮件服务器。尽管 MyBlog 应用程序需要访问这些信息,但你不想这些信息变成公共领域的内容。

直接将此信息嵌入代码中很容易,但这几乎可以保证它会变成公开信息。无意中将私人数据公开可能就像在代码中嵌入私人信息并将其检查到像 GitHub 这样的公开可访问的存储库中一样容易。像 GitHub 这样的服务为开发者提供了宝贵的资源,但它们不会自动保护你不发布你无意发布的内容。

作为一名开发者,你将想要保护自己和你的雇主,确保你的应用程序使用的服务不会被滥用。除了是良好的实践之外,如果这些服务需要付费,未能保护它们可能会让你付出比预期更多的代价。

一种将私有信息与应用程序分离但仍然可以访问的方法是将信息存储在应用程序在运行时可以访问的单独文件中。这些文件不会被提交到版本库中,因此它们不太可能被公开。此外,可以为应用程序可以运行的不同环境(如开发、测试和生产)维护多个版本。

7.7 Flask Debug Toolbar

为了使引入配置文件更有趣,你将使用它们在开发模式下运行 MyBlog 应用程序时,将其添加到 Flask Debug Toolbar (github.com/pallets-eco/flask-debugtoolbar)。当开发 Flask 应用程序时,该工具栏非常有用。它直接在浏览器窗口中显示内部信息,否则这些信息只能通过调试应用程序本身或检查日志信息才能获得。

您将添加的配置文件将控制调试工具栏运行所需的信息,并且仅在开发环境中动态安装工具栏。调试工具栏模块需要存在于当前活动的 Python 虚拟环境中,如下所示:

pip install flask-debugtoolbar==0.11.0

7.7.1 FlaskDynaConf

配置信息存储在 TOML 文件中,这些文件是可读的,并支持其中包含的信息的数据类型。要访问配置 TOML 文件,您需要将 dynaconf 模块安装到当前的 Python 虚拟环境中,如下所示:

pip install dynaconf==3.1.2

该模块包含一个针对 Flask 的特定类,该类会自动将配置信息添加到 Flask 配置系统中。Flask Debug Toolbar 需要 Flask 应用有一个 SECRET_KEY 值。该 SECRET_KEY 值也用于后续章节中使用的 Flask 应用的安全方面。在配置文件中,它看起来是这样的:

secret_key=" <random string of characters>"

提示:遵循自己的建议,示例代码开发中实际使用的密钥值没有公开。密钥值应该是一个不易被公众所知的、具有加密强度的字符串。

您可以使用以下代码生成一个 SECRET_KEY 值:

python
>>> import secrets
>>> print(secrets.token_hex(24))
b3a40bcc3bcc5894c390681396ec04687ad869c6546cdff9    ①

① 示例输出

secrets 模块提供了更适合管理私有信息的加密强度随机值。打印结果作为文件名为 .secrets.toml 中的 secret_key 值被复制在引号之间:

[default]
secret_key="<random string of characters>"

文件结构包含由 [default] 行指示的章节,以及一组数据的关键/值对。因为 secret_key[default] 章节中定义,所以该值在任意其他章节中都是可用的,除非它被另一个 secret_key 关键/值对明确覆盖。完成配置以添加 Flask Debug Toolbar 需要创建另一个 TOML 文件,settings.toml

[default]                                                    ①

[development]                                                ②
flask_debug = true                                           ③
extensions = ["flask_debugtoolbar:DebugToolbarExtension"]    ④
debug_tb_enabled = true                                      ④

[production]                                                 ④
flask_debug = false                                          ⑤

① 当前没有定义默认信息。

② 开始定义开发环境配置信息

③ 启用 Flask 调试模式

④ 开始定义生产环境的配置

⑤ 禁用 Flask 调试模式

配置分为三个部分——[default](目前为空)、[development][production]——可以根据需要分成更多部分。注意列出的值具有数据类型,extension 键的值是一个字符串列表。flask_debug 键/值对具有布尔值 true。每个部分(除 [default] 外)都与在运行应用程序之前初始化的 Flask 环境变量相关联:

export FLASK_ENV=development

在运行时,只会从 settings.toml 文件中读取 [default] 和指示的环境下的信息。settings.toml 文件可以包含在存储库中,因为它对任何想要在应用程序上工作的人来说都很有用,并且不包含机密信息。secrets.toml 文件应从项目存储库中排除,因为它包含不应公开的信息。

配置 MyBlog

要使用配置信息,您需要修改 MyBlog 应用程序。因为配置对应用程序至关重要,所以请在 app/__init__.py 文件中进行以下更改:

import os
import sys
from flask import Flask
from dynaconf import FlaskDynaconf                        ①

def create_app():
    app = Flask(__name__)
    dynaconf = FlaskDynaconf(extensions_list=True)        ②

    with app.app_context():
        os.environ[
➥            "ROOT_PATH_FOR_DYNACONF"
➥        ] = app.root_path                               ③
        dynaconf.init_app(app)                            ④
        app.config["SECRET_KEY"] = 
➥        bytearray(app.config["SECRET_KEY"], "UTF-8")    ⑤
        from . import intro
        app.register_blueprint(intro.intro_bp)
        return app

① 导入 Flask 特定的 dynaconf 类

② 创建 FlaskDynaconf 类的实例,激活模块加载功能

③ 告知 dynaconf 在何处查找配置 *.toml 文件

④ 根据 dynaconf-read 配置文件配置 Flask 应用程序

⑤ 将 SECRET_KEY 字符串转换为字节序列,如 Flask 文档中建议的那样

FlaskDynaconf 类根据文件命名模式和目录结构搜索配置文件,并找到 .secrets.tomlsettings .toml 文件。它解析它们并根据需要配置 Flask 的 app.config 对象。在开发环境中运行 examples/CH_07/examples/05 下的 MyBlog 应用程序,在浏览器中渲染主页,并在屏幕右侧显示 Flask 调试工具栏(图 7.11)。

图 7.11 渲染的页面现在包括 Flask 调试工具栏。

初始视图没有太多区别,除了窗口右上角的小标签页,标签页上标有 FDT,代表 Flask 调试工具栏。点击此标签页将打开工具栏,并显示有关页面以及它提供的工具的一些信息。图 7.12 显示了扩展的工具栏。

图 7.12 展示了可用的 Flask 调试工具栏的扩展视图

点击“模板”菜单项将加载显示的工作区域,其中包含有关主页模板的信息(图 7.13)。这包括模板使用的上下文变量、请求的 URL 以及有关会话的信息。

图 7.13 选择模板工具后,将显示当前模板的信息。

并非所有菜单选项都提供有用的信息。在下一节添加日志配置后,日志选择将不会显示任何内容,但之后会显示。

7.8 日志信息

Flask 使用 Python 日志模块将信息记录到标准输出(STDOUT),正如你在从终端命令行运行 MyBlog 应用程序时所看到的那样。几乎任何开发者的编码都会导致在代码中插入打印语句以获取运行应用程序的信息。输出这类信息的一个更好的方法是使用 Python 日志系统。

记录日志是一种简单的方法,可以获取应用程序在执行过程中的快照。当通过日志系统开发 Web 应用程序时,同样可以提供这种功能。将日志信息记录到STDOUT对于长时间运行的服务器应用程序来说是有益的。

日志系统相对于打印语句有优势;它支持记录信息的严重级别。严重级别允许你提高或降低应用程序将产生的日志级别。例如,你可以在DEBUG级别输出有用的开发信息。在生产环境中运行的应用程序的配置可以提高到DEBUG级别以上,这样就不会产生任何开发者的调试日志。调试日志语句可以保留在代码中。

它还可以提供一个按时间顺序排列的标准格式,这在查找事件或一系列事件的操作顺序时非常有用。Python 日志系统支持多个路径,或处理程序,用于记录不同端点的消息。这些端点可以像记录到STDOUT那样简单,也可以更复杂,例如在记录消息后向服务发送电子邮件或短信。对于 MyBlog 应用程序,日志配置相对简单:以特定的消息格式将日志发送到STDOUT

7.8.1 配置

Python 日志模块支持从字典中进行配置,这正是 MyBlog 将要使用的。配置日志系统是你要添加到应用程序包的create_app()函数中的另一个责任。

如前所述,日志系统使用日志级别,从NOTSETCRITICAL,其中NOTSET等于 0,CRITICAL等于 50。在我的经验中,NOTSET很少使用,它存在是为了“完善集合”。除了提供一些关于记录消息的上下文信息外,日志系统的级别还充当一个简单的过滤器。如果特定记录器的日志系统级别设置为INFO,则只有级别等于INFO或更高的消息会被记录。

DEBUG(值 10)和 INFO(值 20)级别对于 MyBlog 应用程序运行的开发和生产环境都很有兴趣。在开发环境中,可以将级别设置为 DEBUG,,所有级别为 DEBUG 或更高的日志消息都将被记录。在生产环境中,可以将级别设置为 INFO,,所有级别为 INFO 或更高的日志消息都将被记录,而 DEBUG 级别的消息将被忽略。

通过区分两个环境的日志级别,你可以在开发期间将 DEBUG 级别的消息添加到应用程序中,并保留它们。在生产环境中,这些消息不会被记录。配置日志的 Python 字典是通过在 app/__init__.py 中使用支持函数读取日志配置 YAML 文件创建的:

def _configure_logging(app, dynaconf):
    logging_config_path = Path(app.root_path).parent / "logging_config.yaml"
    with open(logging_config_path, "r") as fh:
        logging_config = yaml.safe_load(fh.read())
        env_logging_level = dynaconf.settings.get(
➥            "logging_level", "INFO"
➥        ).upper()
        logging_level = logging.INFO 
➥            if env_logging_level == "INFO" 
➥            else logging.DEBUG
        logging_config["handlers"]["console"]["level"] = logging_level
        logging_config["loggers"][""]["level"] = logging_level
        logging.config.dictConfig(logging_config)

_configure_logging() 函数有一个前置下划线字符,这是一个表示它打算仅对模块私有的约定。前置下划线仅是一个约定,并不会给函数添加任何隐私保护。

函数根据传递给函数的环境字符串创建一个 env_logging_level 变量。该变量用于创建配置字典以控制控制台 STDOUT 处理程序的日志级别。日志配置信息位于项目目录级别的名为 logging_config.yaml 的文件中:

version: 1
disable_existing_loggers: true        ①
formatters:
  default:
    format: '[%(asctime)s.%(msecs)03d] 
➥    %(levelname)s in %(module)s: %(message)s'
    datefmt: '%Y-%m-%d %H:%M:%S'
handlers:
  console:                            ②
    level: DEBUG                      ②
    class: logging.StreamHandler      ②
    formatter: default                ②
    stream: ext://sys.stdout          ②
loggers:
  '':
    level: DEBUG
    handlers: [console]
    propagate: false

① 禁用 Flask 创建的任何现有日志记录器

② 仅配置一个处理程序将错误发送到 STDOUT

结构创建由日志记录器使用的格式化程序,这些格式化程序改变了 MyBlog 应用程序使用的默认日志消息格式。将 logging_level 配置键/值添加到 settings.toml 文件的开发和生产部分,如下所示:

[development]
...
logging_level = "DEBUG"

[production]
...
logging_level = "INFO"

需要对 app/__init__.py 中的 create_app() 函数进行两项更改,以使用 _configure_logging() 函数返回的字典。在导入部分的底部添加这两行代码

import logging
import logging.config

并在将 app.config[SECRET_KEY] 转换为 bytearray 的代码下方立即添加此行到 create_app() 函数中:

_configure_logging(app, dynaconf)    ①

① 调用函数配置 MyBlog 应用的日志记录

你现在可以在需要的地方添加 DEBUG 级别的日志消息,以帮助开发 MyBlog 应用程序。主页和关于页面可以按以下方式修改以演示这一点:

logger = getLogger(__file__)

@intro_bp.route("/")
def home():
    logger.debug("rendering home page")         ①
    return render_template("index.xhtml", data={
        "now": datetime.now(),
        "page_visit": PageVisit(),
        "banner_colors": BannerColors().get_colors()
    })

@intro_bp.route("/about")
def about():
    logger.debug("rendering about page")        ②
    return render_template("about.xhtml")

① 向日志系统发送一条 DEBUG 级别的消息,表示主页已被渲染

② 向日志系统发送一条 DEBUG 级别的消息,表示主页已被渲染

当启动和运行 MyBlog 应用程序时,每当访问应用程序的主页或关于页面时,日志输出中都会出现一条 DEBUG 级别的日志消息。如果你在 examples/CH_07/examples/06 目录下运行代码,并导航到关于和主页,日志输出将类似于以下内容:

[2021-01-08 15:29:39,030] WARNING in _internal:  * Debugger is active!
[2021-01-08 15:29:39,055] INFO in _internal:  * Debugger PIN: 107-111-649
[2021-01-08 15:29:39,104] INFO in myblog: MyBlog is running
[2021-02-03 13:56:57.535] DEBUG in intro: rendering about page
[2021-01-08 15:29:55,707] DEBUG in intro: rendering home page

如果你设置环境变量 FLASK_ENV=production 并运行 MyBlog 应用程序,则应用程序生成的任何 DEBUG 消息都不会出现在日志输出中。在生产环境中抑制 DEBUG 消息可以避免在日志输出中添加开发信息。

7.9 添加 favicon

favicon 是用作代表网站的快捷方式的图形图像。支持 favicon 为 MyBlog 应用程序提供了一些额外的专业性,因此我们将添加一个。

examples/CH_07/examples/07 目录中的代码包含 MyBlog 品牌图形的两个版本;一个是 .ico 格式(图标),另一个是 .svg 格式(可缩放矢量图形)。第一个版本将为 MyBlog 应用程序的浏览器窗口标签页提供小品牌图标。第二个版本将出现在导航栏中的 MyBlog 文本旁边。在此上下文中,品牌图标是与 MyBlog 应用程序相关联的视觉简写。

当浏览器请求时,favicon.ico 文件必须由 MyBlog 应用程序提供。浏览器期望在根文件夹中找到它,它不会是 MyBlog 中的任何 Flask Blueprint 的一部分。通过在 app/__init__.py 文件中直接添加一个路由到它,在 app.app_context() 上下文管理器内部,favicon.ico 文件被提供:

@app.route('/favicon.ico')
def favicon():
    return send_from_directory(
        os.path.join(app.root_path, 'static'),
        'favicon.ico',
        mimetype="image/vnd.microsoft.icon"
    )

这段代码注册了浏览器正在寻找的 favicon() 函数的 URL 路由。Flask 使用 send_from_directory() 函数来获取文件的路径、文件名和 MIME 类型,并将其返回给浏览器。必须导入 send_from_directory() 函数以使其可用。仓库中的代码就是这样做的。

你还需要在 base.xhtml 模板中构建的导航栏中添加 MyBlog 品牌 SVG 图像。图像添加在模板的导航栏品牌部分 MyBlog 文本之前:

<img src="{{url_for('static', filename='images/myblog_brand_icon.svg')}}"
     alt=""
     width="30"
     height="24">

这段 HTML 代码添加了一个图像链接,它找到应用程序根静态文件夹中的 myblog_brand_icon.svg 文件,并适当地缩放以在导航栏中显示。在 examples/CH_07/examples/07 中运行 MyBlog 应用程序会显示包含 favicon.icomyblog_brand_icon.svg 图像文件的浏览器显示。

favicon.ico 文件在包含 MyBlog 应用程序的浏览器标签页中显示。myblog_brand_icon.svg 文件在导航栏中 MyBlog 文本立即左侧显示。两者都在图 7.14 应用程序截图中显示。品牌图形和名称是可点击的链接,将用户返回到应用程序的主页。

图片

图 7.14 浏览器显示,包括 favicon 和 MyBlog 品牌 SVG 图像

7.10 结束语

将 Bootstrap 集成到应用程序中对于使 MyBlog 看起来更加精致和专业大有裨益,同时减少了创建这种外观和感觉的开发工作量。对 MyBlog 进行的重构工作为您提供了一个良好的基础,可以在此基础上进一步扩展应用程序。添加新功能将更加简单,因为可以避免回溯到初始假设。

通过向 MyBlog 添加外部配置文件,您已经启用了多个运行时环境的创建,并将敏感信息移动到了更安全的空间。使用配置文件还允许您将 Flask 调试工具栏和日志级别添加到 MyBlog 应用程序中。

您已经掌握了某些 Web 开发概念和工具,我们可以用放大镜仔细检查它们的细节以及它们是如何相互配合的。能够仔细观察模块的工作方式和连接方式对于添加新功能至关重要。下一章将在此基础上构建,通过添加用户身份验证和创建功能,使 MyBlog 应用程序能够仅对已登录用户安全地显示页面。

摘要

  • 通过使用 Bootstrap 样式框架,我们获得了美观且强大的样式和用户交互,同时保持了 Python 工作的重点。这有助于减少在 Python 和 CSS/JavaScript UI 工作之间的技术领域切换。

  • 从自定义 CSS 迁移到像 Bootstrap 这样的框架不仅减少了样式工作量,而且使网站在不同浏览器和操作系统上的外观和感觉更加一致。

  • 对上一章中的简单 Web 应用程序进行重构使我们能够保持复杂度可控且可扩展。

  • Flask 的蓝图功能为我们提供了一个方便的方式来将相关的 Web 应用程序 URL 端点组织到命名空间中。

  • 应用程序的配置文件应包含公共和私有组件。公共配置信息可以存储在存储库中,以便其他人可以在此应用程序上工作并访问公共配置。私有或机密配置信息绝不应出现在存储库中,因为这可能导致信息泄露。

  • 来自 Web 应用程序的日志信息对于监控应用程序的健康状况和使用情况非常有用。它还提供了有关如何查看和发现问题的有用信息,这些信息有助于调试应用程序。

8 我认识你吗?认证

本章涵盖

  • Flask 会话

  • 记住用户

  • 允许用户登录

  • 注册新用户

MyBlog Web 应用程序支持许多用户,使他们能够发布社区会阅读的引人入胜的内容。此外,该社区可以阅读并评论其他用户发布的内容。然而,用户不太可能希望他们创建的内容被除他们自己以外的用户编辑或删除。

为了控制谁可以访问和使用 MyBlog 网站,我们需要识别用户。在 Web 应用程序中识别用户被称为用户认证。这允许应用程序确保用户是他们所声称的人。

向 MyBlog 应用程序提供身份验证是本章的目的。然而,使用网络应用程序这样做会带来一些独特的挑战。

8.1 HTTP 协议是无状态的

MyBlog Web 应用程序遵循 HTTP 支持的请求/响应模型。用户从浏览器创建一个 HTTP GET 请求,服务器通过发送请求的 HTML、CSS、JavaScript 和图像文件作为响应。在该交易中没有任何内容暗示服务器对其收到的请求有先前的知识。HTTP 协议是无状态的,这意味着每个请求都是完整且独立于任何先前请求的。服务器不维护过去、现在或未来请求/响应事务的内存。

在此模型中,请求必须包含所有必要的信息,以便服务器可以构建适当的响应。即使相同的请求多次发送到服务器,服务器也会每次重建相同的响应。

如果使用 HTTP 协议的服务器没有内存,那么零售购物网站在您购买商品时如何知道您是谁?运行在该网站上的 Web 应用程序如何记住您在多个请求/响应事务中创建的购物车?最重要的是,购物网站如何确保信用卡属于发起购买请求的用户?这些问题的答案涉及在客户端浏览器和 Web 应用程序服务器之间的交易中添加状态信息。

8.1.1 会话

会话允许服务器将有关用户的信息与传入的请求相关联。会话通常通过服务器生成的强加密唯一 ID 值与客户端浏览器中的 cookie 建立关系,并将其保存为 cookie。这被称为会话 cookie,尽管客户端的实际 cookie 名称可能因使用的服务器框架而异。

会话通常包含一个唯一标识符,该标识符在会话创建时由服务器加密。为了防止会话在客户端被修改,唯一标识符在传递给客户端之前被加密。此后,客户端发出的每个请求都包含加密的标识符。

当带有会话的请求到达服务器时,服务器可以解密唯一的标识符并将其关联到特定用户以及服务器维护的任何用户信息——例如,用户的姓名、购物车等。服务器可以通过在 HTTP 协议上叠加会话来保留状态信息,从一次请求到下一次请求。

Flask 会话

Flask 支持使用会话,并将它们提供给 MyBlog 应用程序。会话 cookie 在客户端和服务器之间不存在,直到服务器明确创建它。你可以在你开发的任何 URL 路由处理程序代码中添加信息来创建会话。当访问该 URL 时,会话被创建并添加到客户端作为响应中的 cookie。

Flask 使用我们在上一章配置中创建的 SECRET_KEY 对创建的会话 cookie 进行加密签名。通过这样做,cookie 可以在客户端查看,但除非有 SECRET_KEY,否则无法修改。正如上一章所述,当添加 SECRET_KEY 以启用 Flask 调试工具栏时,SECRET_KEY 必须是加密强健的,并且需要保密。

默认情况下,会话 cookie 存在直到客户端浏览器关闭。这可以通过修改会话的永久属性,即 Python datetime.timedelta() 值来改变。你可以在服务器代码中添加以下内容,使会话存在一年:

session.permanent = True            ①
app.permanent_session_lifetime = 
➥datetime.timedelta(days=365)      ②

① 标记会话为永久

② 使用 Flask 应用实例设置永久会话的生存周期

一旦存在会话,就可以使用它来在请求/响应事务中维护信息。使用会话 cookie 进行信息存储很方便,但有其局限性。cookie 有浏览器强加的内存大小限制,这在不同浏览器之间可能有所不同。cookie 大小的内存限制是一个问题;另一个问题是每次请求/响应消息在互联网上传输的数据大小。

存储在 cookie 中的信息在客户端和浏览器之间的每次事务中都会来回发送。即使在广泛可用的宽带互联网时代,这仍然是一个问题,尤其是对于移动设备。解决这两个问题的方案是使用会话 cookie 来存储唯一的用户标识符值,并在服务器端将其整合以检索构建正确响应所需的所有其他信息。

8.2 记住某人

记住用户让 MyBlog 应用程序有了控制哪些功能对用户可用的方法。例如,博客条目和评论对任何人可见,但添加新博客条目或对现有条目发表评论的能力仅限于已知用户。

之前描述的用户信息可以存储在服务器上,并使用会话 cookie 的唯一用户标识符检索。必须创建并存储一个唯一的用户标识符值,然后用于验证该用户。对于网站来说,识别用户发生在用户通过认证系统登录时。

8.2.1 认证

MyBlog 应用程序使用来自 Python 包索引的 flask_login 扩展模块。flask_login 扩展为 MyBlog 应用程序提供了会话管理能力和登录、注销用户以及处理“记住我”功能的工具。它还增加了对 URL 端点的保护,以确保只有经过认证的用户才能访问受保护的端点。

登录过程遵循常见的电子邮件/密码模式以验证用户。该过程在图 8.1 中展示。用户的电子邮件地址是一个有效的唯一标识符选择,因为它已经唯一且很可能被很好地记住。从高层次来看,您将要创建的登录系统遵循逐步的工作流程。

图片

图 8.1 登录过程的工作流程步骤的视觉表示

用户登录过程遵循以下步骤序列:

  1. 用户从他们的浏览器向认证登录 URL 端点发出 GET 请求。

  2. 认证登录处理程序通过返回渲染的登录 HTML 页面来响应 GET 请求。

  3. 用户填写登录页面表单字段并提交表单。

  4. 表单通过 POST 请求提交到认证登录系统。

  5. 登录系统试图通过使用应用程序支持的模型来找到与匹配的电子邮件和密码的用户。

  6. User 模型试图在应用程序存储系统中找到与匹配的电子邮件和密码的用户。

  7. 如果找到匹配的用户,用户将被引导到主页或用户想要查看的原始目标页面。

由于强大的计算机 CPU 和 GPU 硬件 readily 可用,黑客破解用户密码的能力更容易实现。MyBlog 应用程序使用 Flask_bcrypt 扩展来散列存储在服务器上的密码。bcrypt 功能创建一个计算成本高昂的散列,使其对暴力攻击具有抵抗力,即使随着计算机性能的提高也是如此。

提示:明文密码绝不应该存储在数据库中,而应该首先进行加密散列处理。这意味着如果用户忘记了密码,他们需要重置密码。这也意味着如果黑客设法访问了您的网站数据库,用户账户将得到保护,因为密码是加密的。

图 8.1 展示了由第六步访问的存储机制。flask_login 扩展需要这种存储来持久化用户,以便稍后检索和识别他们。为此,您将使用 SQLAlchemy 来管理 SQLite 数据库中的用户数据。本章主要关注用户认证,并将有关 SQLAlchemy 和使用数据库的更详细信息推迟到第十章。

要安装运行本章示例应用程序所需的所有模块,请在 Python 虚拟环境中运行以下命令,使用存储库中的 requirements.txt 文件:

pip install -r requirements.txt

这使得模块在您创建的代码中可用于添加认证。

LoginManager

首先,将必要的认证、密码加密和用户持久化模块添加到 app/__init__.py 模块中。将它们添加到模块顶部的导入部分,使得这些功能对 create_app() 应用程序工厂函数可用:

import os
import yaml
from pathlib import Path
from flask import Flask, send_from_directory
from dynaconf import FlaskDynaconf
from Flask-sqlalchemy import SQLAlchemy    ①
from flask_login import LoginManager       ②
from Flask_bcrypt import Bcrypt            ③
import logging
import logging.config

① 导入 SQLAlchemy 功能以管理数据持久化

② 导入 LoginManager 以处理用户认证

③ 导入 Bcrypt 模块以对用户密码进行加密

然后,在 create_app() 函数上方,添加新的全局实例变量以实现新功能:

login_manager = LoginManager()                ①
login_manager.login_view = "auth_bp.login"    ②
Flask_bcrypt = Bcrypt()                       ③
db = SQLAlchemy()                             ④

① 创建 LoginManager 类的一个未初始化实例

② 将 LoginManager 实例指向稍后创建的蓝图视图

③ 创建 Bcrypt() 类的一个未初始化实例

④ 创建 SQLAlchemy 类的一个未初始化实例

create_app() 函数的初始化插件部分范围内,使用以下方式使用应用程序实例变量初始化您刚刚创建的新实例变量:

os.environ["ROOT_PATH_FOR_DYNACONF"] = app.root_path
dynaconf.init_app(app)
login_manager.init_app(app)
flask_bcrypt.init_app(app)
db.init_app(app)

在导入路由部分,导入您即将创建的 auth 模块:

from . import intro
from . import auth

在注册蓝图部分,注册一个即将创建的 auth 蓝图:

app.register_blueprint(intro.intro_bp)
app.register_blueprint(auth.auth_bp)

create_app() 函数底部的 return app 行上方添加此新部分:

db.create_all()   ①

① 如果不存在,则创建 SQLite 数据库

新代码使用本章后续部分定义的功能。这项工作在访问或导入 app 包时初始化认证、加密和数据库系统。下一步是创建处理用户认证功能的 auth 蓝图。

Auth 蓝图

intro 蓝图一样,auth 蓝图是一个包含独立功能的 Python 包。在应用程序包下创建一个名为 auth 的目录,然后在 auth 目录中创建一个 __init__.py 文件。__init__.py 文件生成并初始化 auth_bp 蓝图实例,就像我们之前做的那样:

from flask import Blueprint

auth_bp = Blueprint(
    "auth_bp", __name__,
    static_folder="static",
    static_url_path="/auth/static",
    template_folder="templates"
)

from . import auth

此代码在访问或导入 auth 包时创建 auth_bp 蓝图实例。实际的认证功能包含在 app/auth 目录中创建的 auth.py 文件中:

from logging import getLogger
from flask import render_template, redirect, url_for, request
from . import auth_bp                                            ①
from ..models import db_session_manager, User                    ②
from .. import login_manager                                     ③
from .forms import LoginForm                                     ④
from flask-login import login_user, logout_user, current_user
from werkzeug.urls import url_parse

logger = getLogger(__name__)

@login_manager.user_loader                                       ⑤
def load_user(user_id):                                          ⑤
    with db_session_manager() as db_session:                     ⑤
        return db_session.query(User).get(user_id)               ⑤

@auth_bp.route("/login", methods=["GET", "POST"])                ⑥
def login():
    form = LoginForm()
    if form.validate_on_submit():
        with db_session_manager() as db_session:                 ⑦
            user = db_session.query(User)
            ➥.filter(User.email == form.email.data)
            ➥.one_or_none()                                     ⑧
            if user is None or not                               ⑨
            ➥user.verify_password(form.password.data):          ⑨
                flash("Invalid email or                          ⑨
                ➥password", "warning")                          ⑨
                return redirect(                                 ⑨
                ➥url_for("auth_bp.login"))                     ⑨
            login_user(user,                                     ⑩
            ➥remember=form.remember_me.data)                    ⑩
            next = request.args.get("next")                      ⑩
            if not next or                                       ⑩
            ➥url_parse(next).netloc != "":                      ⑩
                next = url_for("intro_bp.home")                  ⑩
            return redirect(next)                                ⑩
    return render_template("login.xhtml", form=form)

① 从包中导入 auth_bp 蓝图实例

② 导入将要创建的模型

③ 从包中导入 login_manager 实例

④ 导入 LoginForm,它将在下一节中创建

⑤ 每次登录管理器需要确定用户是否存在时调用的函数

⑥ 为/login路由注册的与 auth_bp 蓝图相关联的登录函数

⑦ 开始数据库会话上下文管理器范围,以便在范围结束时关闭数据库会话

⑧ 根据表单电子邮件值从数据库中获取用户

⑨ 如果找不到用户或密码验证失败,警告用户并重定向到登录页面。

⑩ 设置已登录用户并创建会话 cookie

auth.py模块创建了一个名为"/login"的路由,与auth_bp蓝图实例相关联,并将其与login()处理函数相关联。login()处理函数有两个目的。当它因 HTTP GET请求而被调用时,它返回渲染的login.xhtml模板,该模板将在下一节中创建。如果该函数因 HTTP POST请求而被调用,它将处理login.xhtml模板中的表单参数内容。

if form.validate_on_submit()代码确定 HTTP 请求方法是否为GETPOST,并相应地进行分支。如果方法是POST,它将验证表单参数与在LoginForm类中配置的规则集。如果表单参数有效,该函数将采取以下行动:

  • 使用表单电子邮件参数从数据库中获取用户。

  • 如果用户不存在或表单密码参数无效:

    • 向用户显示警告消息并重新渲染登录屏幕。Flask 使用术语Flash表示向用户展示额外信息。
  • 如果用户确实存在且表单密码参数有效:

    • 更新登录管理器系统关于用户的信息,并创建一个会话 cookie 以记住他们。
  • 获取用户在登录操作中尝试导航到的页面。

  • 验证该页面的请求是否有效,如果netloc属性有效。

  • 如果netloc为空,则将用户重定向到该页面或主页。

用户模型

向 MyBlog 应用程序添加登录机制的一个目标是在 HTTP 协议之上叠加状态信息以记住用户。使用flask_login系统,你正接近这个目标,但我们需要一个唯一的标识符来保存到会话 cookie 中。该标识符可以用来检索有关用户的信息。

这两者都要求我们定义和实现一个用户数据结构。我们将创建一个 Python SQLAlchemy 类—User—它将被存储在 SQLite 数据库中。

SQLite 是一个关系型数据库系统,Python 可以通过模块访问它。关于数据库及其使用 SQLAlchemy 访问的深入讨论将在第十章中介绍。图 8.2 展示了你将存储关于已登录用户的所有信息——他们的姓名、电子邮件、哈希密码以及他们是否活跃。创建和更新字段是简单的审计信息,显示了记录创建或更新的时间戳。

![图 8.2 Farrell

图 8.2 显示 User 表字段及其数据类型的 ERD(实体关系图)

MyBlog 应用程序将存储和展示它记住的信息,例如用户、博客内容和评论。每个项目都需要在创建更多应用程序支持的功能时进行定义和实现。因为 MyBlog 应用程序可以展示的所有内容都存储在数据库中,所以你将首先使用一个数据库术语——模型

app/models.py 模块包含所有数据库模型,这些模型定义并实现了 MyBlog 应用程序存储的所有内容。因为你需要一个 User 模型来允许用户登录系统,所以现在让我们创建 app/models.py 文件。在 app/models.py 文件中要做的第一件事是导入所需的模块:

from contextlib import contextmanager
from flask_bcrypt import (
    generate_password_hash, 
    check_password_hash
)
from . import db
from flask_login import UserMixin
from uuid import uuid4
from datetime import datetime

import 语句使 app/models.py 模块能够访问创建 User 类所需的功能。User 类与 flask_login 扩展一起使用,以验证 MyBlog 应用程序的用户。验证用户意味着识别和验证用户是否为 MyBlog 应用程序所知,并且可以访问那些用户可用的功能。

我们将从创建 User 类开始,该类包含有关用户的信息——他们的姓名、电子邮件地址和密码,以及将与用户关联的唯一 ID,该 ID 将存储在 HTTP 会话 cookie 中。

UUID 数据库主键

当创建数据库表时,一个常见的做法是使用自动递增的整数值作为与表中每个记录相关联的唯一 ID。相反,MyBlog 应用程序将使用 UUID 值作为这个唯一 ID,称为 主键,用于表中的记录。UUID 是一个全球唯一的由字母数字字符组成的长字符串。采用这种方法的优缺点在第十章中进行了讨论。

创建了一个小函数,在创建 User 记录并将其插入数据库时提供 UUID 字符串值:

def get_uuid():
    return uuid4().hex

get_uuid() 函数使用导入的 uuid4() 函数创建 UUID 值,然后返回该值的 hex 字符串版本。返回 hex 字符串版本使 UUID 值略短一些。

接下来,你必须将 User 类的定义添加到 app/models.py 中。这个类使用多重继承从 app/models.py 顶部导入的模块中获取内置功能。

第一个是 UserMixin 类,它提供了 flask_login 系统期望子类可用的方法来访问 User 信息。第二个是来自 db 实例变量的 db.Model 类,该实例变量在应用模块中创建和初始化。db.Model 类为从其继承的子类提供了与数据库交互和定义与类属性关联的表中的列所需的 SQLAlchemy 功能:

class User(UserMixin, db.Model):                                          ①
    __tablename__ = "user"                                                ②
    user_uid = db.Column(db.String, primary_key=True, default=get_uuid)   ③
    first_name = db.Column(db.String, nullable=False)                     ④
    last_name = db.Column(db.String, nullable=False)                      ④
    email = db.Column(db.String, nullable=False,                          ④
    ➥unique=True, index=True)                                            ④
    hashed_password = db.Column("password",                               ④
    ➥db.String, nullable=False)                                          ④
    active = db.Column(db.Boolean, nullable=False, default=False)         ④
    created = db.Column(db.DateTime, nullable=False,                      ⑤
    ➥default=datetime.utcnow)                                            ⑤
    updated = db.Column(db.DateTime, nullable=False,                      ⑤
    ➥default=datetime.utcnow, onupdate=datetime.utcnow)                  ⑤

    def get_id(self):
        return self.user_uid

    @property
    def password(self):
        raise AttributeError("user password can’t be read")

    @password.setter
    def password(self, password):
        self.hashed_password = generate_password_hash(password)

    def verify_password(self, password):
        return check_password_hash(self.hashed_password, password)

    def __repr__(self):
        return f"""
        user_uid: {self.user_uid}
        name: {self.first_name} {self.last_name}
        email: {self.email}
        active: {'True' if self.active else 'False'}
        """

① 用户类多重继承自 UserMixin 和 db.Model 类。

② 定义此类记录将存储在数据库中的表名

③ 使用 get_uuid 函数定义用户记录的唯一 ID 值

④ 定义其他用户属性

⑤ 定义记录审计时间戳属性

User 类继承自导入的 UserMixindb.Model 类。这意味着 User 类是 UserMixin 类和 db.model 类的 IS-A 类,并且可以访问这两个类的所有方法和属性。

通过继承 UserMixin 类,User 类获得了由在应用包中创建的 LoginManager() 实例所需的方法。这些方法使用在 User 类中定义的属性来验证用户。

User 类也继承自 db.Model,这是将 SQLAlchemy 功能添加到类中的方式,使其能够访问数据库。User 模型定义了名为 "user" 的表中单行数据的结构,其中每个定义的属性都是数据库表记录中的一个列。

get_id() 方法覆盖了由 UserMixin 类提供的同名方法,并替换了其功能。默认的 get_id() 方法返回一个 self.id 值,但由于 User 类将唯一 ID 属性名称定义为 user_uid,因此需要覆盖默认行为。get_id() 方法在 LoginManager 实例需要确定会话 cookie 中存储的唯一标识符是否与系统中的真实用户相关时使用。

注意创建在 User 类上的只写属性的两个 password() 方法。由于密码是经过密码学散列的,因此读取密码没有帮助(甚至不可能),因此用 @property 装饰的方法会引发属性错误。

@password.setter 装饰的 password() 方法创建写行为。该方法拦截设置密码属性,并生成密码的密码学强散列,该散列存储在 hashed_password 类属性中。尽管 hashed_password 是属性名称,但相应的数据库列命名为 "password"verify_password() 方法在 auth.py 模块中使用,以确定从登录表单模板检索到的密码是否与为用户存储的散列版本匹配。

8.2.2 登录

编写 HTML 表单以收集用户输入的电子邮件和密码是一个简单的过程。带有提交按钮的表单内容可以作为 HTTP POST 请求发送到 MyBlog 服务器,然后你可以处理表单信息。

由于用户在输入表单数据时可能会无意或有意地犯错,因此验证表单输入信息是必要的。例如,电子邮件和密码是否在所需的长度限制内?电子邮件和密码是否都存在?电子邮件地址是否符合标准格式?实现这些验证步骤是额外的工作,而且很难做对。幸运的是,还有一个 Flask 扩展可以极大地简化表单处理——Flask-WTF

Flask-WTF

Flask-WTF 扩展将更通用的 WTForms 包集成到 Flask 中。使用此扩展允许你将 MyBlog 服务器代码绑定到 HTML 表单元素,并在使用 HTTP POST 方法处理接收到的相应表单时自动处理这些元素。要创建登录表单及其验证,向 app/auth 包中添加一个名为 forms.py 的新文件:

from flask_wtf import FlaskForm                         ①
from wtforms import PasswordField,                      ②
➥BooleanField, SubmitField                             ②
from wtforms.fields.xhtml5 import EmailField             ②
from wtforms.validators import DataRequired,            ③
➥Length, Email, EqualTo                                ③

class LoginForm(FlaskForm):                             ④
    email = EmailField(                                 ⑤
        "Email",                                        ⑤
        validators=[DataRequired(), Length(             ⑤
            min=4,                                      ⑤
            max=128,                                    ⑤
            message="Email must be between 4            ⑤
            ➥and 128 characters long"                  ⑤
        ), Email()],                                    ⑤
        render_kw={"placeholder": " "}                  ⑤
    )                                                   ⑤
    password = PasswordField(                           ⑥
        "Password",                                     ⑥
        validators=[DataRequired(), Length(             ⑥
            min=3,                                      ⑥
            max=64,                                     ⑥
            message="Password must be between 3         ⑥
            ➥and 64 characters long"                   ⑥
        )],                                             ⑥
        render_kw={"placeholder": “ “}                  ⑥
    )                                                   ⑥
    remember_me = BooleanField(" Keep me logged in")    ⑦
cancel = SubmitField(                                   ⑧
    label="Cancel",                                     ⑧
    render_kw={"formnovalidate": True},                 ⑧
    )                                                   ⑧
    submit = SubmitField("Log In")                      ⑨

① 导入 FlaskForm 类

② 导入用于在表单中创建的字段类型类

③ 导入用于验证表单元素的字段验证类

④ 创建继承自基本 FlaskForm 类的 LoginForm

⑤ 创建电子邮件表单元素和验证器

⑥ 创建密码表单元素和验证器

⑦ 创建记住我表单元素

⑧ 创建表单取消按钮

⑨ 创建表单提交按钮

LoginForm 类定义了一个在表单渲染时创建的对象,包含表单中的所有 HTML 元素。元素有一个必需的第一个参数——用于标签、元素表单名称和 ID 值的名称。validators 参数定义了一个列表,该列表定义了元素必须通过的验证步骤,以便表单有效。render_kw 参数是可选的,它定义了与元素一起渲染的附加 HTML 表单属性。

email 元素是 EmailField 类的一个实例。类构造函数有一个参数 "Email",它被用作元素渲染的任何标签,并在用于 HTML DOM(文档对象模型)中的名称和 ID 值时转换为小写。validators 定义了一个条目是必需的;长度必须在 4 到 128 个字符之间,且元素值必须符合有效的电子邮件格式。

password 元素是 PasswordField 类的一个实例。PasswordField 将元素渲染为 HTML 类型的密码,因此用户输入的文本显示为一系列星号字符。类构造函数有一个参数 "Password",其用法与电子邮件元素相同。validators 定义了一个密码是必需的,且长度必须在 3 到 64 个字符之间。

注意电子邮件和密码属性上的 render_kw={"placeholder": “} 参数。这些是使 Bootstrap 样式的输入元素的视觉功能按预期工作的必要条件。

remember_me 元素是 BooleanField 类的一个实例。这将被渲染为一个带有标签“Keep me logged in.”的 HTML 复选框。

cancel 元素是 SubmitField 类的一个实例,创建了一个当点击时会将用户带到主页的取消按钮。submit 元素也是 SubmitField 类的一个实例,当表单被渲染时创建提交按钮。有了 forms.py 模块,你需要创建一个 HTML 页面,其中 LoginForm 类将渲染包含的元素。

登录表单

login.xhtml 文件创建在 app/auth/templates 目录中,其中 auth_bp 蓝图可以访问它。

{% extends "base.xhtml" %}                                                      ①
{% import "macros.jinja" as macros %}                                          ②

{% block content %}
<div class="login-wrapper mx-auto mt-3">
    <div class="container login">
        <form method="POST" novalidate>
            <div class="card">
                <h5 class="card-header">
                     User Login
                </h5>
                <div class="card-body">
                    <div class="card-text">
                        {{form.csrf_token}}                                    ③
                        <div                                                   ④
                        ➥class="form-floating mb-3">                          ④
                            {{form.email(                                      ④
                            ➥class_="form-control")}}                         ④
                            {{form.email.label(                                ④
                            ➥class_="form-label")}}                           ④
                            {{macros.                                          ④
                            ➥validation_errors(form.email.errors)}}           ④
                        </div>                                                 ④
                        <div class="mb-3">                                     ⑤
                            {{form.password(                                   ⑤
                            ➥class_="form-control")}}                         ⑤
                            {{form.password.label(                             ⑤
                            ➥class_="form-label")}}                           ⑤
                            {{macros.validation_errors(form.password.errors)}} ⑤
                        </div>                                                 ⑤
                        <div class="mb-3">                                     ⑥
                            {{form.remember_me}}                               ⑥
                            {{form.remember_me.label(                          ⑥
                            ➥class_="form-check-label")}}                     ⑥
                        </div>                                                 ⑥
                    </div>
                </div>
             <div class="card-footer text-end">
                 {{form.cancel(
                 ➥class_="btn btn-warning me-2")}}                            ⑦
                 {{form.submit(
                 ➥class_="btn btn-primary")}}                                 ⑧
                </div>
            </div>
    </form>
    </div>
</div>
{% endblock %}

{% block styles %}
    {{ super() }}
    <link rel="stylesheet" type="text/css" 
    ➥href="{{ url_for('.static', 
    ➥filename='css/login.css') }}" />
{% endblock %}

① 登录.xhtml 模板继承自 base.xhtml,因此它获得了 MyBlog 页面的所有元素。

② 导入 macros.jinja 宏文件,我们将在下一节讨论

③ 在本章末尾审查跨站请求伪造保护令牌

④ 在页面上创建电子邮件元素,传递元素 Bootstrap 类信息

⑤ 在页面上创建密码元素,传递元素 Bootstrap 类信息

⑥ 在页面上创建“记住我”元素,传递元素 Bootstrap 类信息

⑦ 创建取消按钮,传递元素 Bootstrap 类信息

⑧ 创建提交按钮,传递元素 Bootstrap 类信息

回想一下 auth.py 模块和分配给 /login 路由的 login() 处理程序。有两行代码与渲染登录表单和将其连接到在 forms.py 中创建的 LoginForm 实例相关:

form = LoginForm()

return render_template("login.xhtml", form=form)

这两行代码在通过 HTTP GETPOST 请求调用 login() 处理程序时非常重要。第一行创建了 LoginForm 类的实例变量。第二行将那个 form 实例传递给 render_template 作为第二个参数,这样在渲染 login.xhtml 模板元素时,Jinja 模板引擎就可以访问 form 实例。这就是如何将 LoginForm 定义实例连接到 login.xhtml 模板的方式。

当接收到 GET 请求时,模板引擎使用表单实例帮助在浏览器窗口中渲染它。当接收到 POST 请求时,表单体包含用户输入的表单数据,这些数据填充了 LoginForm 属性。每个属性的表单验证方法都会运行以验证表单数据是否符合验证要求。

examples/CH_08/examples/01 目录中运行代码并导航到 127.0.0.1:5000/login,将如图 8.3 截图所示的登录表单展示给用户。登录表单提供了用户输入他们的电子邮件和密码以及他们是否应该保持登录状态的功能。

图 8.3 MyBlog 生成的登录界面允许用户输入他们的电子邮件和密码以访问网站。

Jinja 宏

注意 login.xhtml 模板中电子邮件和密码定义部分这一行:

{{macros.validation_errors(form.email.errors)}}

这引用了 app/templates/macros.jinja 文件中的一个 Jinja 宏,该宏在 login.xhtml 模板的顶部导入。validation_errors() 宏处理向用户显示任何 LoginForm 验证错误,以便它们可以被纠正:

{% macro validation_errors(errors) %}
    {% if errors %}
        {% for error in errors %}
            <div class="text-danger small">{{error}}</div>
        {% endfor %}
    {% endif %}
{% endmacro %}

宏是 Jinja 模板引擎中的一个函数定义,就像定义 Python 函数一样。validation_errors() 宏接收一个 LoginForm 验证错误列表。它首先检查是否有任何错误,如果有,就遍历这个列表,在验证失败的表单字段下方以小红色文字显示错误信息。输入无效的电子邮件地址和仅两个字符的密码会导致 login.xhtml 模板渲染,并包含向用户指示问题的错误消息。这些错误显示在图 8.4 截图上。

图 8.4 如果电子邮件或密码无效时显示的错误

如果你输入一个有效长度且可接受的电子邮件地址和密码,并点击提交按钮,你会发现没有任何操作发生。login.xhtml 模板再次渲染,并且没有向用户展示任何关于发生了什么(如果有的话)的信息。回顾一下 auth.py 文件中的 login() 函数,在尝试通过电子邮件地址查找用户之后,代码中有一个条件检查:

user = db_session.query(User)
➥.filter(User.email == form.email.data)
➥.one_or_none()
if user is None or not user.verify_password(form.password.data):
    flash("Invalid email or password", "warning")
 return redirect(url_for("auth_bp.login"))

如果找不到用户,则其值为 None,代码将执行 flash() 消息函数并将用户重定向到登录路由;login.xhtml 模板再次渲染。没有找到用户,因为应用程序中还没有创建任何用户。为此条件运行了预期的代码,用户被重定向到登录屏幕。然而,为什么 flash() 函数没有通过显示无效的电子邮件或密码消息来通知用户呢?

8.3 新闻快讯

Flask 的 flash() 函数为用户提供关于应用程序中事件和活动的反馈。当创建一个消息并发送到 flash() 函数时,该消息会被追加到下一个请求上下文中可用的消息列表中,并且只有下一个请求。这使得这些闪存消息对下一个渲染的模板可用。

模板必须访问消息列表并将消息添加到渲染的 HTML 中以显示闪存消息。一种直接的方法是使用 Jinja 的 for 循环遍历闪存消息列表,并将消息作为渲染 HTML 的一部分创建一个 HTML 无序列表。我们将使用 Bootstrap 来渲染消息,以便临时显示它们,而不会破坏模板的样式和展示。

8.3.1 改进登录表单

Bootstrap 框架提供了一个名为 toasts 的组件,这些是轻量级的警告消息,它们“漂浮”在内容上方。Toast 在移动和桌面操作系统上都变得非常流行。在 MyBlog 应用程序中,它们非常有用,因为它们不会破坏模板布局,并且是瞬时的,消息展示后很快就会消失。

由于任何 URL 端点处理器都可以调用 flash() 函数,因此将闪存消息的处理集中化是有用的。base.xhtml 模板是理想的,因为它旨在被 MyBlog 系统中的每个模板继承。

创建一个 Bootstrap toast 需要大量的 HTML 代码,这些代码需要添加到 base.xhtml 模板文件中。更好的选择是将闪存消息处理从 base.xhtml 模板中提取出来,并创建一个 Jinja 宏。flask_flash_messages() 宏函数被添加到 app/templates/macros.jinja 文件中:

{% macro flask_flash_messages() %}                               ①
  {% with messages = get_flashed_messages(
  ➥with_categories=true) %}                                     ②
    {% if messages %}                                            ③
      <div aria-live="polite" 
           aria-atomic="true"
           class="position-relative">
        <div class="toast-container position-absolute top-0 end-0 p-3"
             style="z-index: 2000; opacity: 1;">
          {% for category, message in messages %}                ④
            {% set category = "white" if 
            ➥category == "message" else category %}
            {% set text_color = "text-dark" if category in [
              "warning",
              "info",
              "light",
              "white",
              ] else "text-white"
            %}
            <div class="toast bg-{{category}}" 
                role="alert" 
                aria-live="assertive" 
                aria-atomic="true">
              <div class="toast-header bg-{{category}} {{text_color}}">
                {% set toast_title = category if category in [
                  "success", "danger", "warning", "info"
                ] else "message" %}
                <strong class="me-auto">MyBlog: {{toast_title.title()}}</strong>
                <button type="button" 
                        class="btn-close" 
                        data-bs-dismiss="toast" 
                        aria-label="Close"></button>
              </div>
              <div class="toast-body {{text_color}}">
                {{message}}
              </div>
            </div>
          {% endfor %}
        </div>
      </div>
    {% endif %}
  {% endwith %}
{% endmacro %}

① 开始定义 flask_flash_messages() 宏

② 开始使用 with 上下文块来获取闪存消息

③ 是否有需要处理的闪存消息?

④ 开始循环遍历闪存消息列表

大多数的 flask_flash_messages() 宏都关注于生成用于展示 toast 消息所需的 Bootstrap 样式。这些 toast 消息被添加到渲染的模板中,但不会立即显示给用户。为了实现这一点,需要 JavaScript 代码来显示这些消息。JavaScript 代码必须在渲染继承自 base.xhtml 的模板时运行,因此创建一个 app/static/js/base.js 文件,如下所示:

(function() {
    var option = {
        animation: true,
        delay: 3000
    }
    var toastElements = [].slice.call(document.querySelectorAll('.toast'))
    toastElements.map(function (toastElement) {
        toast = new bootstrap.Toast(toastElement, option)
        toast.show()
    })
}())

这段代码创建了一个自调用的匿名 JavaScript 函数,这意味着它在浏览器 JavaScript 引擎解析代码时立即运行。这种函数在你想立即运行一些代码并保持变量不在全局 JavaScript 作用域中时非常有用。因为 base.js 被包含在 base.xhtml 模板的末尾,所以函数在 HTML DOM 元素(包括 toast 元素)创建之后运行。

option 变量是一个 JavaScript 对象字面量,类似于 Python 中的字典。它包含传递给 Bootstrap Toast 类以动画化 toast 消息并在 3000 毫秒或 3 秒后移除的消息配置信息。

然后该函数创建了一个包含页面中所有 toast HTML DOM 元素的 toastElements 数组变量。JavaScript 中的数组类似于 Python 中的列表。JavaScript 数组有一个名为 map 的方法,它将一个函数应用于数组中的每个元素。传递给 map 的匿名函数创建一个新的 Toast 实例,传递 option 对象,然后调用 show() 方法在浏览器窗口中显示 toast 消息。

假设你在 examples/CH_08/examples/02 运行应用程序,并输入有效的电子邮件地址和密码,但 MyBlog 应用程序不知道这些值。在这种情况下,登录页面将重新渲染,显示有关电子邮件或密码无效的 toast 消息。图 8.5 展示了产生的错误截图。

图片

图 8.5 展示了包含错误信息的 Bootstrap 通知消息的渲染模板

现在用户理论上可以登录 MyBlog 应用程序,是时候允许新用户使用应用程序注册,以便他们在登录时有一个账户!

8.4 结交新朋友

在 MyBlog 应用程序上注册新用户也使用 flask_login 扩展。注册新用户的过程遵循与登录过程类似的模式,如图 8.6 所示。它不是寻找用户,而是创建并保存一个到数据库中。

图片

图 8.6 注册新用户过程工作流程步骤的视觉表示

注册新用户的过程如下:

  1. 用户从浏览器向认证注册新用户 URL 端点发出 GET 请求。

  2. 认证注册新用户处理程序通过返回渲染的注册新用户 HTML 页面来响应 GET 请求。

  3. 用户填写注册新用户页面表单字段并提交表单。

  4. 使用 POST 请求将表单提交到认证注册新用户系统。

  5. 注册新用户系统使用应用程序支持的模式从表单数据创建用户。

  6. User 模型将新创建的用户保存到应用程序存储系统中。

  7. 用户被引导到认证登录页面以输入他们的登录凭证。

8.4.1 认证蓝图

注册新用户处理程序位于 app/auth/auth.py 文件中。与登录处理程序一样,从 forms.py 模块中的 FlaskForm 类派生出一个名为 RegisterNewUserForm 的表单。将此类实例添加到 app/auth.py 中导入 LoginForm 类的代码行:

from .forms import LoginForm, RegisterNewUserForm

在文件底部添加一个新的处理程序:

@auth_bp.route("/register_new_user",                  ①
➥methods=["GET", "POST"])                            ①
def register_new_user():                              ①
    if current_user.is_authenticated:                 ②
        return redirect(url_for("intro_bp.home"))     ②
    form = RegisterNewUserForm()                      ③
    if form.validate_on_submit():                     ④
        with db_session_manager() as db_session:      ⑤
            user = User(                              ⑤
                first_name=form.first_name.data,      ⑤
                last_name=form.last_name.data,        ⑤
                email=form.email.data,                ⑤
                password=form.password.data,          ⑤
                active=True                           ⑤
            )                                         ⑤
            db_session.add(user)                      ⑥
            db_session.commit()                       ⑥
            logger.debug(f"new user                   ⑥
            ➥{form.email.data} added")               ⑥
            return redirect(url_for("auth_bp.login")) ⑥
    return render_template("register_new_user.xhtml", 
    ➥form=form)                                      ⑦

① 将 register_new_user() 函数标记为 /register_new_user 路由的 GET 和 POST HTTP 请求的处理程序

② 如果用户已经认证,则将其重定向到主页。

③ 创建一个 RegisterNewUserForm 实例

④ 如果 HTTP 请求是 POST,则验证传入的表单数据。

⑤ 使用表单数据初始化属性创建新用户

⑥ 将新创建的用户添加到数据库,记录新用户创建,并重定向到登录页面

⑦ 如果 HTTP 请求是 GET,则渲染空的 register_new_user.xhtml 模板,传入表单实例以供模板使用。

8.4.2 新用户表单

重复用于登录表单的模式,创建了一个 Flask-WTForm 和 HTML 模板文件,以完成注册新用户的功能。将 RegisterNewUserForm 类定义添加到 app/auth/forms.py 文件中:

from wtforms.validators import DataRequired, Length, 
➥Email, EqualTo                                      ①
from wtforms import ValidationError                   ①
from ..models import User, db_session_manager         ①

: intervening code

class RegisterNewUserForm(FlaskForm):                 ②
    first_name = StringField(                         ③
        "First Name",                                 ③
        validators=[DataRequired()],                  ③
        render_kw={"placeholder": " ",                ③
        ➥"tabindex": 1, "autofocus": True}           ③
    )                                                 ③
    last_name = StringField(                          ③
        "Last Name",                                  ③
        validators=[DataRequired()],                  ③
        render_kw={"placeholder": " ",                ③
        ➥"tabindex": 2}                              ③
    )                                                 ③
    email = EmailField(                               ③
        "Email",                                      ③
        validators=[DataRequired(), Length(           ③
            min=4,                                    ③
            max=128,                                  ③
            message="Email must be between 4 and      ③
            ➥128 characters long"                    ③
        ), Email()],                                  ③
        render_kw={"placeholder": " ", "tabindex": 3} ③
    )                                                 ③
    password = PasswordField(                         ③
        "Password",                                   ③
        validators=[DataRequired(), Length(           ③
                min=3,                                ③
                max=64,                               ③
                message="Password must be between     ③
                ➥3 and 64 characters long"           ③
            ),                                        ③
            EqualTo("confirm_password",               ③
            ➥message="Passwords must match")         ③
        ],                                            ③
        render_kw={"placeholder": " ", "tabindex": 4} ③
    )                                                 ③
    confirm_password = PasswordField(                 ③
        "Confirm Password",                           ③
        validators=[DataRequired(), Length(           ③
            min=3,                                    ③
            max=64,                                   ③
            message="Password must be between         ③
            ➥3 and 64 characters long"               ③
        )],                                           ③
        render_kw={"placeholder": " ", "tabindex": 5} ③
    )                                                 ③
create_new_user = SubmitField("Create New User",      ④
➥render_kw={"tabindex": 6})                          ④
    cancel = SubmitField("Cancel",                    ④
    ➥render_kw={"tabindex": 7})                      ④

① 需要添加到导入部分的新条目

② 定义了 RegisterNewUserForm 类

③ 创建了 first_name、last_name、email、password 和 confirm_password 表单元素和验证器

④ 创建表单提交按钮

此代码创建传递给注册新用户模板的表单,以创建渲染的 HTML DOM 元素,并在通过 POST 请求提交表单时应用验证规则。在 RegisterNewUserForm 类底部还需要添加一个额外的方法:

def validate_email(self, field):
    with db_session_manager() as db_session:
        user = db_session.query(User)
➥.filter(User.email == field.data)
➥.one_or_none()
        if user is not None:
            raise ValidationError("Email already registered")

validate_email() 方法是一种自定义验证,确保新用户不会使用系统中已存在的电子邮件地址。FlaskForm 类具有对从它继承的类进行内省的功能。这种内省找到了 validate_email() 方法并将其添加到电子邮件表单字段的验证中。

处理程序中创建的 RegisterNewUserForm 类实例被传递到 register_new_user.xhtml 模板以渲染用户的表单。这个模板类似于 login.xhtml 模板,此处未展示。然而,你可以通过编辑 examples/CH_08/examples/03/app/auth/templates/register_new_user.xhtml 文件来查看模板。

如果你切换到 examples/CH_08/examples/03 目录并运行 MyBlog 应用程序,然后导航到 127.0.0.1:5000/register_new_user 路由,图 8.7 中的表单将在浏览器中渲染。该表单提供了新用户输入他们的名字、姓氏、电子邮件和密码以及确认密码的字段。当点击创建新用户按钮时,表单数据将被发送到服务器,并检查电子邮件地址是否已在系统中存在。如果电子邮件在 MyBlog 应用程序中未知,则创建新用户并将其保存到数据库中。

图 8.7 在浏览器中渲染的新用户创建表单

8.4.3 哦,对了:注销

现在用户可以登录 MyBlog 应用程序,我们还需要提供一个注销的方法。除了良好的对称性外,从认证应用程序中注销对于用户能够控制谁可以使用他们的凭据访问应用程序至关重要。

对于 MyBlog 应用程序,注销功能是通过向 auth 模块添加另一个 URL 路由来创建的。当用户导航到注销路由时,不会显示任何模板。相反,路由处理程序重置会话 cookie 并将用户重定向到应用程序主页。因为主页对任何用户(无论是否认证)都可用,所以这是一种合理的方法。

app/auth/auth.py 模块中,修改 from flask_login 行如下:

from flask_login import login_user, logout_user, current_user

将以下内容添加到 app/auth.py 模块的底部:

@auth_bp.route("/logout")                        ①
def logout():                                    ①
    logout_user()                                ②
    flash("You've been logged out", "light")     ③
    return redirect(url_for("intro_bp.home"))    ④

① 添加了注销用户的新路由和处理程序

② 调用 flask_login 的 logout_user() 函数来注销用户

③ 向用户显示一条消息,告知他们已被注销

④ 将用户重定向到应用程序主页

8.5 接下来是什么

我们已经建立了认证系统的基本框架,但需要添加更多功能使其完全有用。在下一章中,你将为导航系统添加登录功能,以便用户可以轻松登录和注销。我们还将添加确认用户电子邮件地址的功能,这将完成验证用户身份的闭环。

如果用户忘记了密码,他们需要重置密码,并查看和编辑他们的个人资料。我们还将这些功能添加到 MyBlog 应用程序中。

我们还将为用户添加授权角色,以帮助 MyBlog 应用程序控制用户在登录应用程序时可以执行的操作。这些角色将控制谁可以创建内容,谁可以编辑内容,以及谁可以激活和停用该内容。一旦我们建立了认证和授权机制,我们就可以使用这些概念来保护应用程序中的路由,以确保只有经过认证且具有特定角色的用户才能导航并查看特定的 URL 路由。

摘要

  • 认证的全部内容都是关于以一致、可靠的方式识别某人。使用 HTTP 协议实现这一点需要一些思考和代码。

  • Flask 框架和第三方模块提供了工具,帮助开发者管理用户和登录/注销过程。

  • Flask 的 flash 功能结合 Bootstrap 提供了一种既美观又实用的方式,可以在不干扰工作流程或网站设计的情况下向用户发送消息。

9 你能做什么?授权

本章涵盖

  • 将登录/退出添加到页面导航

  • 通过电子邮件确认新用户

  • 允许用户重置忘记的密码

  • 允许现有用户更改密码

  • 向用户添加授权角色

  • 在应用程序中确保路由安全

在上一章中,您创建了支持用户在 MyBlog 应用程序中登录和退出的功能。登录和退出是用户必须能够轻松访问的基本功能。因此,您将此导航功能添加到父base.xhtml模板中,以便在 MyBlog 应用程序的任何地方都可以使用。

9.1 登录/退出导航

您已创建了一个工作的认证系统,但目前它主要通过在浏览器导航栏中输入 URL 来访问。让我们将登录/退出 URL 路由添加到 Bootstrap 导航系统中。

认证系统有两个相互排斥的状态,作为用户;您只能登录或注销。因此,认证系统在导航菜单中表现为一个单项目,根据用户的当前认证状态在状态之间切换。遵循单一责任和不过度复杂化base.xhtml模板的理念,登录/退出菜单功能将作为examples/CH_09/examples/01/app/templates/macros.jinja文件中的 Jinja 宏存在:

{% macro build_login_logout_items(current_user) %}      ①
    {% if not current_user.is_authenticated %}          ②
        {% if request.endpoint == "auth_bp.login" %}    ③
            <a class="nav-link ml-2 active"             ③
            ➥aria-current="page"                       ③
            ➥href="{{url_for('auth_bp.login')}}">      ③
        {% else %}                                      ③
            <a class="nav-link ml-2"                    ③
            ➥href="{{url_for('auth_bp.login')}}">      ③
        {% endif %}                                     ③
        Login
        </a>
    {% else %}                                          ④
        <a class= "nav-link ml-2"                       ⑤
        ➥href=" {{url_for('auth_bp.logout')}}">        ⑤
        Logout                                          ⑤
        </a>                                            ⑤
    {% endif %}
{% endmacro %}

① 开始构建build_login_logout_items宏,从base.xhtml模板传递当前用户

② 当前用户是否未认证?

③ 根据当前路由,突出显示或未突出显示登录菜单项和路由

④ 否则,如果当前用户已认证

⑤ 显示退出菜单项和路由

build_login_logout_items()宏将根据用户的认证状态切换导航显示,显示“登录”或“退出”。这两个菜单项与登录和退出 URL 端点相关联。

该宏被添加到base.xhtml模板中,以便系统可以在 MyBlog 应用程序展示的任何页面上渲染它。修改base.xhtml模板中创建导航菜单的代码部分,以添加此功能:

<div class=" collapse navbar-collapse 
➥\1"                    ①
  id= "navbarSupportedContent">
    <div class=" navbar-nav mr-auto">
     {{ macros.build_nav_item(nav_item) }}
 </div>
 <div class="navbar-nav">                     ②
     {{ macros.build_login_logout_items(      ②
     ➥current_user) }}                       ②
 </div>                                       ②
</div>

① 将两个navbar-nav部分右对齐和左对齐

② 创建第二个navbar-nav部分并调用宏以渲染登录/退出项

在上述更改到位后,当点击并渲染登录菜单项时,MyBlog 网络应用程序将显示突出显示的登录菜单项。从examples/CH_09/examples/01目录运行应用程序,并查看渲染的登录菜单项。图 9.1 是更新后的登录页面的截图。

图片

图 9.1 用户登录注册表单,包括新添加的登录菜单项

9.2 确认新朋友

当一个潜在用户在 MyBlog 应用程序中注册系统时,确认他们的身份是很重要的。这通常是通过向他们注册的电子邮件地址发送带有确认链接的电子邮件来完成的。由于 MyBlog 应用程序使用用户的电子邮件地址作为唯一标识符,向该地址发送确认电子邮件就完成了用户意图注册 MyBlog 应用程序的闭环。我们将添加从 MyBlog 应用程序发送电子邮件的功能,以便我们可以发送确认电子邮件。

9.2.1 发送电子邮件

与使用 SQLite 作为数据库类似,我们将实现一个简单的电子邮件系统,适用于 MyBlog。为了帮助将重点放在 MyBlog 上,我使用了一个名为 SendInBlue 的电子邮件服务提供商(www.sendinblue.com/)。我已经设置了一个免费账户,允许 MyBlog 应用程序每月免费发送 300 封电子邮件(对于这本书来说,300 封已经足够了)。

SendInBlue 提供了一个可安装的 API 模块,允许 Python 应用程序通过函数调用发送电子邮件。此模块可以使用以下命令安装:

pip install sib-api-v3-sdk

然而,该模块包含在这一章的requirements.txt文件中,并在你运行时安装了它。

pip install -r requirements.txt

在你为这一章构建 Python 虚拟环境的时候。SendInBlue 服务处理发送电子邮件的所有细节,简化了我们需要为 MyBlog 应用程序编写的代码。

小贴士:通过使用像 SendInBlue 这样的外部服务,我们避免了需要设置 SMTP(简单邮件传输协议)服务器。这不是一个小任务,并且超出了这本书的范围。

当新用户在 MyBlog 应用程序的新用户注册表单中注册时,我们想要添加两个功能:

  • 在用户数据库模型中添加一个名为confirmed的布尔字段,初始设置为False

  • 向注册用户电子邮件地址发送带有确认链接的电子邮件的功能

在用户模型中添加一个confirmed字段很简单,可以在这一章代码库中的examples/CH_09/examples/02/app/models.py代码示例中看到。

邮件发送器

我们可以直接从auth.py模块的register_new_user()函数发送电子邮件,这会工作得很好。然而,我们可能希望从 MyBlog 应用程序的其他地方发送电子邮件,所以我们将该功能嵌入到一个新的模块中,以便可以重用。

我们将创建一个新的模块,如下所示,名为app/emailer.py,它包含一个名为send_mail()的单个函数:

from logging import getLogger

import sib_api_v3_sdk                                       ①
from flask import current_app
from sib_api_v3_sdk.rest import ApiException

logger = getLogger(__name__)
configuration = sib_api_v3_sdk.Configuration()              ②
configuration.api_key['api-key'] =                          ②
➥current_app.config.get("SIB_API_KEY")                     ②

def send_mail(to, subject, contents):
    api_instance = sib_api_v3_sdk.TransactionalEmailsApi(
    ➥sib_api_v3_sdk.ApiClient(configuration))              ③
    smtp_email = sib_api_v3_sdk.SendSmtpEmail(              ④
        to=[{"email": to}],                                 ④
        html_content= contents,                             ④
        sender={"name": "MyBlog", "email":                  ④
        ➥"no-reply@myblog.com"},                           ④
        subject=subject                                     ④
    )                                                       ④
    try:
        api_instance.send_transac_email(smtp_email)         ⑤
        logger.debug(f"Confirmation email sent to {to}")
    except ApiException as e:
        logger.exception("Exception sending email", exc_info=e)

① 导入 SendInBlue API 模块

② 使用你的 API 密钥配置 API

③ 创建 API 实例

④ 创建邮件对象

⑤ 将要发送的邮件对象发送出去

此代码创建了一个 SendInBlue API 实例,并使用用户的 API 密钥进行了配置。我在使用 SendInBlue 创建账户时收到了 API 密钥——它位于secrets.toml文件中。

API 实例变量 api_instance 用于发送电子邮件对象。API 预期电子邮件内容为 HTML 格式,因此发送的消息必须包含一些基本的 HTML 标签以正确渲染电子邮件。

确认电子邮件

现在 MyBlog 可以发送电子邮件,让我们用它来向新注册用户发送确认电子邮件。确认电子邮件将包含一个链接回 MyBlog 应用程序。该链接包含用户点击链接时发送的加密信息。当 MyBlog 处理对链接的调用时,它会解密信息以确定请求是否有效。如果是,则用户在数据库中被确认。

编码的信息还包含当前时间戳。当点击链接并且应用程序处理该请求时,时间戳将与当前时间进行比较。如果应用程序在超时期间处理请求,则用户已确认。然而,如果用户等待时间超过定义的超时时间,则确认链接被视为过期,用户不会被确认。超时值在 settings.toml 文件中设置为 12 小时,可以更改。

我们将在 register_new_user() 函数处理程序中添加两个函数调用以发送新用户确认电子邮件。第一个是调用新函数 send_confirmation_email(user),第二个是调用 Flask 的 flash() 函数,通过弹窗消息通知用户检查确认电子邮件:

@ auth_bp.get("/register_new_user")
@ auth_bp.post("/register_new_user")
def register_new_user():
    if current_user.is_authenticated:
        return redirect(url_for("intro_bp.home"))
    form = RegisterNewUserForm()
    if form.cancel.data:
        return redirect(url_for("intro_bp.home"))
    if form.validate_on_submit():
        with db_session_manager() as db_session:
            user = User(
                first_name=form.first_name.data,
                last_name=form.last_name.data,
                email=form.email.data,
                password=form.password.data,
                active=True
            )
            role_name = "admin" if user.email in 
            ➥current_app.config.get("ADMIN_USERS") else "user"
            role = db_session.query(Role).filter(Role.name == 
            ➥role_name).one_or_none()
            role.users.append(user)
            db_session.add(user)
            db_session.commit()
            send_confirmation_email(user)         ①
            timeout = current_app.config.get(     ②
            ➥"CONFIRMATION_LINK_TIMEOUT")        ②
            flash((                               ②
                "Please click the confirmation    ②
                ➥link just sent "                ②
                f"to your email address within    ②
                ➥{timeout} hours "               ②
                "to complete your registration"   ②
                ➥))                              ②
            logger.debug(f"new user {form.email.data} added")
            return redirect(url_for("intro_bp.home"))
    return render_template("register_new_user.xhtml", form=form)

① 调用新的 send_confirmation_email(user) 函数发送电子邮件

② 调用 Flask 的 flash() 功能来通知用户在确认链接超时时间内检查他们的电子邮件

让我们来看看 send_confirmation_email() 函数:

def send_confirmation_email(user):
    confirmation_token = user.confirmation_token()    ①
    confirmation_url = url_for(                       ②
        "auth_bp.confirm",                            ②
        confirmation_token=confirmation_token,        ②
        _external=True                                ②
    )                                                 ②
    timeout = current_app.config.get(                 ③
    ➥"CONFIRMATION_LINK_TIMEOUT")                    ③
    to = user.email                                   ③
    subject = "Confirm Your Email"                    ③
    contents = (                                      ③
        f"""Dear {user.first_name},<br /><br />       ③
        Welcome to MyBlog, please click the link to   ③
        ➥confirm your email within {timeout} hours:  ③
        {confirmation_url}<br /><br />                ③
        Thank you!                                    ③
        """                                           ③
    )                                                 ③
    send_mail(to=to, subject=subject,                 ③
    ➥contents=contents)                              ③

① 调用新的用户方法来构建确认令牌

② 构建一个要插入电子邮件中的 URL,当点击时,将通知 MyBlog 用户已确认

③ 构建并发送带有确认 URL 的电子邮件给用户

send_confirmation_email() 函数调用 User 模型的新方法 confirmation_token() 来构建一个带有过期超时的唯一令牌。然后构建一个指向新 URL 处理器的 URL,即 auth_bp.confirm。最后,_external = True 参数导致 url_for() 创建一个完整的 URL,当用户从他们的电子邮件客户端上下文点击链接时,该 URL 将会工作。

一旦创建确认链接,就会创建一个包含确认链接的电子邮件并发送给新用户。如果新用户在 12 小时时间限制内点击确认链接,则他们的账户将被确认。

注意电子邮件消息中的 <br /><br /> HTML 换行元素。这些 HTML 元素有助于格式化消息,使其易于用户阅读。

用户确认令牌

由于确认令牌对每个用户都是唯一的,因此它是由附加到 User 模型类的新的方法生成的:

def confirmation_token(self):
    serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
    return serializer.dumps({"confirm": self.user_uid})

此方法使用URLSafeTimedSerializer()函数根据 Flask 的SECRET_KEY创建一个序列化实例,并包括当前时间戳。然后,使用序列化实例根据新用户的user_id值创建唯一的令牌。

确认用户处理器

当新用户点击他们电子邮件中的确认链接时,此操作会向auth模块中的新 URL 处理器发出请求,以确认请求中传递的令牌是有效的:

@ auth_bp.get("/confirm/<confirmation_token>")      ①
@ login_required                                    ②
def confirm(confirmation_token):
    if current_user.confirmed:                      ③
        return redirect(url_for("intro_bp.home"))   ③
    try:
        # is the confirmation token confirmed?
        if current_user.confirm_token(
        ➥confirmation_token):                      ④
            with db_session_manager() 
            ➥as db_session:                        ⑤
                current_user.confirmation = True    ⑤
                db_session.add(current_user)        ⑤
                db_session.commit()                 ⑤
                flash("Thank you for confirming your account")
    # confirmation token bad or expired
    except Exception as e:                          ⑥
        logger.exception(e)                         ⑥
        flash(e.message)                            ⑥
        return redirect(url_for(                    ⑥
        ➥"auth_bp.resend_confirmation"))           ⑥
    return redirect(url_for("intro_bp.home"))

① 使用 Blueprint 注册新的/确认 URL 路由

② 确认令牌,要求用户登录

③ 如果用户已经确认,则将其重定向到主页。

④ 确认令牌有效

⑤ 如果令牌有效,将当前用户的确认状态设置为 True,并将其保存在数据库中。

⑥ 如果确认令牌引发异常,则记录它,通知用户,并将他们重定向到重新发送确认页面。

用户确认令牌

应用程序需要确认通过点击电子邮件中的链接收到的令牌是有效的,以验证用户已完成注册过程。此代码作为确认过程的一部分:

def confirm_token(self, token):
    serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
    with db_session_manager() as session:
        confirmation_link_timeout =  current_app.config.get("CONFIRMATION_LINK_TIMEOUT")
        timeout = confirmation_link_timeout * 60 * 1000
        try:
            data = serializer.loads(token, max_age=timeout)
            if data.get("confirm") != self.user_uid:
                return False
            self.confirmed = True
            session.add(self)
            return True
        except (SignatureExpired, BadSignature) as e:
            return False

confirm_token() URL 处理器创建一个序列化实例,就像confirmation_token()创建方法一样。然后,它进入数据库上下文管理器,获取确认超时值和请求中发送的确认数据。

然后,代码将数据字典中的"confirm"值与用户的user_id值进行比较。如果值匹配,点击链接的用户就是发送确认链接的用户。确认令牌的代码被包裹在异常处理器中,如果令牌已过期或无效,则返回False

9.3 重置密码

我们已经到了新用户可以注册并确认他们的电子邮件,现有用户可以登录使用 MyBlog 应用程序的点。我们需要为现有用户提供一种方法,以便他们在忘记密码时可以重置密码。在某种程度上,密码重置请求与确认新用户相似;它向用户的电子邮件发送一个链接。存在一个处理器,当用户点击相关链接时,它会向用户呈现密码重置表单。

发送到重置密码电子邮件中的链接包含请求用户的加密user_uid值,以及过期超时。超时值在settings.toml文件中设置为 10 分钟,并可配置:

@ auth_bp.get("/request_reset_password")                     ①
@ auth_bp.post("/request_reset_password")                    ①
def request_reset_password():
    if current_user.is_authenticated:
        return redirect("intro_bp.home")
    form = RequestResetPasswordForm()                        ②
    if form.cancel.data:
        return redirect(url_for("intro_bp.home"))
    if form.validate_on_submit():
        with db_session_manager() as db_session:             ③
            user = (                                         ③
                db_session.query(User)                       ③
                .filter(User.email ==                        ③
                ➥form.email.data)                           ③
                .one_or_none()                               ③
            )                                                ③
            if user is not None:
                send_password_reset(user)                    ④
                timeout = current_app.config.get(            ④
                ➥"PASSWORD_RESET_TIMEOUT")                  ④
                flash(f"Check your email to reset            ④
                ➥your password within {timeout} minutes")   ④
                return redirect(url_for("intro_bp.home"))    ④
    return render_template(                                  ④
    ➥"request_reset_password.xhtml", form=form)    #E

① 为 GET 和 POST HTTP 方法注册 request_reset_password 函数

② 创建请求密码表单的实例

③ 从表单中获取与电子邮件关联的用户

④ 发送密码重置电子邮件,并通知当前用户在超时内检查它

examples/CH_09/examples/03目录中的应用程序在接收到 HTTP GET请求时呈现重置密码表单,如图 9.2 所示。密码重置表单仅呈现一个用于用户电子邮件的单个字段,该字段将用于生成包含密码重置链接的电子邮件。

图片

图 9.2 允许注册用户重置密码的表单

如果在表单中输入的电子邮件找到了用户,则将该用户作为参数传递给新的函数send_password_reset()

def send_password_reset(user):
    timeout = current_app.config.get(                         ①
    ➥"PASSWORD_RESET_TIMEOUT")                               ①
    token = user.get_reset_token(timeout)                     ①
    to = user.email                                           ②
    subject = "Password Reset"                                ②
    contents = (                                              ②
        f"""{user.first_name},<br /><br />                    ②
        Click the following link to reset                     ②
        ➥your password within {timeout} minutes:             ②
        {url_for('auth_bp.reset_password',                    ②
        ➥token=token, _external=True)}                       ②
        If you haven't requested a password                   ②
        ➥reset ignore this email.<br /><br />                ②
        Sincerely,                                            ②
        MyBlog                                                ②
        """                                                   ②
    )
    send_mail(to=to, subject=subject, contents=contents)      ③

① 使用过期超时创建加密的重置令牌

② 构建电子邮件内容

③ 将电子邮件发送到传入用户电子邮件地址

当用户点击重置密码电子邮件中的链接时,会调用一个新的 URL 端点函数reset_password()

@ auth_bp.get("/reset_password/<token>")                ①
@ auth_bp.post("/reset_password/<token>")               ①
def reset_password(token):
    if current_user.is_authenticated:
        return redirect("intro_bp.home")
    try:
        user_uid = User.verify_reset_token(token)       ②
        with db_session_manager() as db_se              ③
            user = (                                    ③
                db_session                              ③
                    .query(User)                        ③
                    .filter(User.user_uid ==            ③
                    ➥user_uid)                         ③
                    .one_or_none()                      ③
            )                                           ③
            if user is None:
                flash("Reset token invalid")
                return redirect("intro_bp.home")
            form = ResetPasswordForm()
            if form.cancel.data:
                return redirect(url_for("intro_bp.home"))
            if form.validate_on_submit():
                user.password = form.password.data      ④
                db_session.commit()                     ④
                flash("Your password has been reset")
                return redirect(url_for("intro_bp.home"))
    except Exception as e:
        flash(str(e))
        logger.exception(e)
        return redirect("intro_bp.home")
    return render_template("reset_password.xhtml", form=form)

① 为 GET 和 POST HTTP 方法注册了request_reset_password函数

② 从与 URL 一起传递的加密令牌中获取user_uid

③ 找到与user_uid匹配的用户

④ 更新并保存用户的新密码

图 9.3 所示的形式允许用户输入并确认新密码。当用户点击重置密码按钮时,处理程序会使用 HTTP POST方法被调用。user_uid从与 URL 一起传递的令牌中解密,然后在数据库中搜索该用户。如果找到用户且表单验证通过,则用户的密码将在数据库中更新并保存。

图片

图 9.3 重置密码表单接受新密码和匹配的确认密码。

9.4 用户资料

MyBlog 应用目前仅保存有关注册用户的一些信息:名字、姓氏、电子邮件和密码;是否已确认;以及是否活跃。除了在忘记密码时能够重置密码外,用户还希望更改密码。因此,我们将添加一个资料页面,显示大部分用户信息,并允许更改密码。

资料是一个展示和收集信息的表单。它显示了用户的名字和电子邮件,并具有输入字段以输入和确认新密码。展示资料信息的表单类被添加到auth/forms.py文件中:

class UserProfileForm(FlaskForm):
    first_name = StringField("First Name")
    last_name = StringField("Last Name")
    email = EmailField("Email")
    password = PasswordField(
        "Update Password",
        validators=[DataRequired(), Length(
                min=3, 
                max=64, 
                message= "Password must be between 3 and 64 characters long"
            ),
            EqualTo("confirm_password", message="Passwords must match")
        ]
    )
    confirm_password = PasswordField(
        "Confirm Updated Password",
        validators=[DataRequired(), Length(
            min=3, 
            max=64, 
            message= "Password must be between 3 and 64 characters long"
        )]
    )
    cancel = SubmitField(
        label= "Cancel",
        render_kw={"formnovalidate": True},
    )
    submit = SubmitField(label="Okay")

要在浏览器上生成显示的 HTML,需要在auth/auth.py模块中添加一个新的 URL 处理程序:

@ auth_bp.get("/profile/<user_uid>")                    ①
@ auth_bp.post("/profile/<user_uid>")                   ①
@ login_required                                        ②
def profile(user_uid):
    with db_session_manager() as db_session:            ③
        user = (                                        ③
            db_session                                  ③
                .query(User)                            ③
                .filter(User.user_uid == user_uid)      ③
                .one_or_none()                          ③
        )                                               ③
        if user is None:                                ④
            flash("Unknown user")                       ④
            abort(404)                                  ④
        if user.user_uid != current_user.user_uid:      ⑤
            flash("Can't view profile                   ⑤
            ➥for other users")                         ⑤
            return redirect("intro_bp.home")            ⑤
        form = UserProfileForm(obj=user)
        if form.cancel.data:
            return redirect(url_for("intro_bp.home"))
        if form.validate_on_submit():
            user.password = form.password.data          ⑥
            db_session.commit()                         ⑥
            flash("Your password has been updated")
            return redirect(url_for("intro_bp.home"))
    return render_template("profile.xhtml", form=form)

① 为 GET 和 POST HTTP 方法注册了资料功能

② 要查看资料,用户必须登录(如下一节所述)。

③ 获取 URL 路径中与user_uid关联的用户

④ 如果未找到用户,则通知用户并使用 404 错误终止。

⑤ 防止用户查看除自己资料外的其他资料

⑥ 对于有效的表单提交,此操作将更新用户的密码。

展示用户资料的 HTML 模板在此处未显示,但可以在examples/CH_09/examples/03/auth/templates/profile.xhtml模板文件中找到。如图 9.4 所示的渲染后的资料页面显示了用户信息,并提供了一种更改用户密码的方式。

图片

图 9.4 用户资料页面显示了 MyBlog 应用了解的所有用户信息。

9.5 安全性

构建身份验证系统的目标是提供对应用程序用户、功能和功能的保护。安全包括用户在使用应用程序时可以执行的功能和功能。它还包括通过维护控制和仅允许已知用户访问受保护的功能和功能来保护应用程序。

9.5.1 保护路由

已验证的用户有一个应用程序可以识别的加密安全会话 cookie。此外,我们可以使用会话和flask_login模块来保护应用程序中的路由,以确保只有登录并验证的用户才能导航到这些路由。

目前,MyBlog 应用程序只有两个与身份验证无关的路由——主页和关于页面。因此,你将暂时创建两个新的路由来演示如何保护路由。通过向 URL 路由页面处理程序添加flask_login模块提供的另一个装饰器来保护页面。将此添加到app/intro.py模块的导入部分:

from flask_login import login_required     ①

① 从flask_login模块导入login_required装饰器功能

app/intro.py模块中添加一个新的路由和处理程序:

@ intro_bp.route("/auth_required")     ①
@ login_required                       ②
def auth_required():
    return render_template("auth_required.xhtml")

① 为"/auth_required"添加一个新的路由

② 使用login_required功能装饰auth_required处理程序

auth_required()处理程序有两个装饰器:@intro_bp.route()@login_required。以这种方式堆叠装饰器是完全可行的。装饰器功能围绕其他装饰器功能包装,从内部级别向外工作。在这种情况下,必须将@login_required装饰器放在@intro_bp.route()(或任何蓝图实例路由)之后,以确保@login_required功能包装auth_required()处理程序功能。

通过@login_required装饰器保护auth_required()处理程序,未验证的用户将被重定向到登录页面,无法访问受保护的auth_required页面。这在您只想允许验证用户查看敏感或私人信息或防止访问可能更改服务器数据或功能的功能表单时非常有用。这种安全性的一个示例用例是仅允许验证用户在 MyBlog 应用程序中创建和发布博客内容。

9.6 用户授权角色

身份验证硬币的另一面是授权。当身份验证提供了一个识别用户的机制时,授权提供了一种控制用户能力的方式。

MyBlog 应用程序的一个要求是为用户提供角色。一个角色将允许具有特定角色的用户执行其他用户无法执行的操作。例如,具有管理员角色的用户可以更新、激活或停用系统中的任何内容,而不仅仅是该用户创建的内容。同样,管理员也可以激活或停用用户。

拥有编辑者角色的用户可以更新系统中的任何内容,而不仅仅是他们创建的内容。然而,编辑者不能停用用户或他们的内容。

已注册的用户可以创建内容并激活或停用它们,但不能更改其他用户的活跃状态或他们的内容。我们将在 MyBlog 应用程序中添加三个角色:管理员、编辑者和注册用户。

9.6.1 创建角色

角色将由应用程序初始化并在数据库中维护。数据库中的用户与定义的角色有关联。因为许多用户可以拥有某个角色,但每个用户只能有一个角色,所以我们有一个关于角色到用户的单向多对一关系。图 9.5 中显示的 ERD 阐述了这种关系。

图 9.5 新的 Role 表及其与现有 User 表的关系

Role 模型定义在 examples/CH_09/examples/03/app/models .py 文件中:

class Role(db.Model):                                             ①
    class Permissions(Flag):                                      ②
        REGISTERED = auto()                                       ②
        EDITOR = auto()                                           ②
        ADMINISTRATOR = auto()                                    ②

    __tablename__ = "role"                                        ③
    role_uid = db.Column(db.String,                               ④
    ➥primary_key=True, default=get_uuid)                         ④
    name = db.Column(db.String, nullable=False,                   ④
    ➥unique=True)                                                ④
    description = db.Column(db.String,                            ④
    ➥nullable=False)                                             ④
    raw_permissions = db.Column(db.Integer)                       ④
    users = db.relationship("User", 
    ➥backref=db.backref("role", lazy="joined"))                  ⑤
    active = db.Column(db.Boolean, nullable=False, default=True)
    created = db.Column(db.DateTime, 
    ➥nullable=False, default=datetime.now(
    ➥tz=timezone.utc))
    updated = db.Column(
        db.DateTime,
        nullable=False,
        default=datetime.now(tz=timezone.utc),
        onupdate=datetime.now(tz=timezone.utc)
    )

    @property                                                     ⑥
    def permissions(self):                                        ⑥
        return Role.Permissions(                                  ⑥
        ➥self.raw_permissions)                                   ⑥

① 创建角色类模型

② 在角色类内部创建权限类

③ 定义数据库中角色的表名

④ 为角色记录创建列

⑤ 建立与用户表的单一多对一关系

⑥ 为角色的权限创建一个只读属性

此代码创建了 Role 数据库定义类。注意 Permissions 类在 Role 类定义的作用域内定义。这是可接受的 Python 语法,并将 Permission 类放在 Role 类的作用域内。

Permissions 类是一个 Flag 枚举,并自动生成 REGISTEREDEDITORADMINISTRATOR 名称值。这个类帮助通过名称引用值,尽管 permission 值存储在数据库的 raw_permissions 列中作为整数。

角色数据库表中的值需要在 MyBlog 应用程序的生命周期内存在,并作为常量的查找表。Role 类定义中的一个名为 initialize_role_table() 的方法实现了这一点。该方法用 @staticmethod 装饰,意味着它可以不通过 Role 实例变量来调用。该方法的目的是在应用程序启动时填充 Roles 表。该方法在此处未包含,但可以在 examples/CH_09/examples/03/app/models.py 文件中找到。

要初始化 Roles 表,以下代码被添加到 examples/CH_09/examples/03/app/__init__.py 文件中 create_app() 函数的底部:

# initialize the role table
from .models import Role
Role.initialize_role_table()

这些行导入 Role 表类,然后使用它来调用 initialize_role_table() 静态方法以填充 Roles 数据库表。为了预期即将到来的功能,以下代码也被添加到 create_app() 函数的末尾:

# inject the role permissions class into all template contexts
@ app.context_processor
def inject_permissions():
    return dict(Permissions=Role.Permissions)

这些代码行将 Role.Permissions 属性添加到模板上下文中。这使得 Role.Permissions 在所有模板中作为 Permissions 可用。

9.6.2 授权路由

除了保护 MyBlog 应用程序中的 URL 路由,只允许经过身份验证的用户访问它们之外,你还希望保护 URL 路由,只允许具有特定权限的经过身份验证的用户访问它们。这在后续章节中创建仅应由编辑或管理员访问的表单时将很有用。

要创建此功能,你需要创建一个类似于 @login_required 的装饰器,但它应该检查用户的授权。为此,在应用目录内创建另一个模块,app/decorators.py

from functools import wraps
from flask import abort
from flask_login import current_user

def authorization_required(permissions):             ①
    def wrapper(func):                               ②
        @wraps(func)                                 ③
        def wrapped_function(*args, **kwargs):       ④
            if not current_user.role.permissions
            ➥ & permissions:                        ⑤
                abort(403)                           ⑥
            return func(*args, **kwargs)             ⑥
        return wrapped_function
    return wrapper

① 创建一个装饰器函数,期望传递一个权限掩码

② 创建一个包装器来接收包装的函数

③ 使用 @wraps(func) 装饰器来保持包装函数的签名

④ 创建一个包装器来接收包装的函数的参数

⑤ 确定当前用户是否具有访问此路由所需的权限

⑥ 如果用户没有所需的权限,则使用 HTTP 403 错误代码中止

让我们演示 authorization_required() 装饰器函数。更新 app/intro/intro.py 模块,并将此代码添加到导入部分的底部:

from ..decorators import authorization_required
from ..models import Role

添加这些行后,创建一个新的 URL 路由和处理程序:

@ intro_bp.route("/admin_required")                   ①
@ login_required                                      ②
@ authorization_required(
➥Role.Permissions.ADMINISTRATOR)                     ③
def admin_required():                                 ③
    return render_template("admin_required.xhtml")     ③

① 为 "/admin_required" 添加一个新的路由

② 使用 login_required 功能装饰 admin_required 处理程序

③ 使用 authorization_required 功能装饰 admin_required 处理程序

在此路由就绪后,你可以运行应用程序并尝试导航到 URL http:/ /127.0.0.1/admin_required。系统将为除具有管理员权限的用户外的所有用户生成一个 403 错误(Forbidden)。如何创建管理员将在下一章中介绍。

创建管理员用户

MyBlog 应用程序有一个相对简单的方法来创建管理员用户。在 secrets.toml 文件中,有一个类似这样的代码段:

admin_users = ["user's email you want to designate as an administrator"]

这创建了一个配置变量 admin_users,它是一个电子邮件地址列表。当新用户使用此列表中的电子邮件注册时,将为他们分配管理员角色。通过将 admin_users 变量设置为列表,你可以在 MyBlog 应用程序中拥有多个管理员。

小贴士 以描述的方式创建管理员(或管理员)角色在 MyBlog 应用程序中效果很好。它也可以用来创建编辑者,但你必须提前知道编辑者用户才能将他们放入 secrets.toml 文件中。创建一个应用程序的管理界面将创建表单,允许创建和更新其他角色。那是另一天的工作。

在之前的配置就绪后,我们可以通过修改 examples/CH_09/examples/03/app/auth/auth.py 文件并添加三行到 register_new_user() 函数来激活此功能:

@ auth_bp.get("/register_new_user")
@ auth_bp.post("/register_new_user")
def register_new_user():
    if current_user.is_authenticated:
        return redirect(url_for("intro_bp.home"))
    form = RegisterNewUserForm()
    if form.cancel.data:
        return redirect(url_for("intro_bp.home"))
    if form.validate_on_submit():
        with db_session_manager() as db_session:
            user = User(
                first_name=form.first_name.data,
                last_name=form.last_name.data,
                email=form.email.data,
                password=form.password.data,
            )
            role_name = "admin" if user.email 
            ➥in current_app.config.get("ADMIN_USERS") else
            ➥"user"                                            ①
            role = db_session.query(Role)
            ➥.filter(Role.name == role_name).one_or_none()     ②
            role.users.append(user)                             ③
            db_session.add(user)
            db_session.commit()
            send_confirmation_email(user)
            timeout = current_app.config.get("CONFIRMATION_LINK_TIMEOUT")
            flash((
                "Please click the confirmation link just sent"
                f" to your email address within {timeout} hours"
                "to complete your registration"
            ))
            logger.debug(f"new user {form.email.data} added")
            return redirect(url_for("intro_bp.home"))
    return render_template("register_new_user.xhtml", form=form)

① 如果注册用户的电子邮件在 admin_users 列表中,则赋予他们管理员角色。

② 从角色表中获取分配的角色

③ 将注册用户添加到角色集合中,连接关系

新代码在admin_user配置变量中查找当前注册的用户,并使用适当的值创建role_name变量。然后,它使用role_name变量在Roles表中执行查找以获取指定的role。然后,用户被添加到role.users集合中,将角色与用户连接起来,建立关系。如果你在secrets.toml文件中的admin_users列表中创建一个新用户,并导航到之前创建的/admin_required URL,你将能够成功导航到该页面。

9.7 保护表单

我们还忽略了一种与表单相关的保护。在login.xhtmlregister_new_user.xhtml模板中,表单上下文中有一个看起来像这样的字段:

<form action="" method="POST" novalidate>
    {{form.csrf_token}}
    <!—rest of the form 
</form>

{{form.csrf_token}} Jinja 替换元素是什么?如果你查看loginregister_new_user页面的源代码,你会看到一个看起来像这样的<input...>元素:

<input id="csrf_token" name="csrf_token" 
➥type="hidden" 
➥value="IjE1NzU4NjE3OWNlMTUxYmM0Yzc3OTAyTOZiODk4N
➥jRmNTdmZGM5OGUi.
➥YEULPg.jVDKYLM3MMlpKK-BQSh2f1hWUfQ">

该元素是一个隐藏的输入元素(不会在浏览器页面上显示)并具有一个看起来很奇怪的值。MyBlog 应用程序服务器使用 Flask 的SECRET_KEY配置值和用户会话的唯一标识符来生成这个值。这个元素的目的是防止跨站请求伪造(CSRF)攻击。form.csrf_token旨在保护将要执行操作(如 HTTP POST)的请求。当服务器收到受保护的表单时,它将验证会话和form.csrf_token以确保恶意用户没有修改它。

这种保护是通过使用Flask-WTF模块自动提供的。你只需在你想保护的任何表单中包含{{form.csrf_token}}即可。

9.8 总结

你通过使用你所学的新模块——flask_loginFlask_bcryptFlask-WTFFlask-SQLAlchemy——创建了一个有效的认证和授权系统。确保用户安全是应用程序被用户接受的关键步骤。你创建的授权系统对 MyBlog 应用程序来说是功能性和有价值的。然而,它远非安全领域的终结。例如,假设你需要更紧密地保护一个 Web 应用程序。你需要考虑双因素认证,或者更现实的是,使用第三方服务来托管你的认证。

你已经将你的应用程序的用户认证/授权聚焦到了几乎微观的程度。这种详细程度有助于创建一个有用且实用的登录/注销系统,以保护 MyBlog 及其系统本身的用户。

下一章将放大你对这里介绍的数据库信息的视野。然后,你将更深入地了解设计数据库表及其关系以及 SQLAlchemy 如何整合 Python 和数据库世界。

概述

  • 利用 base.xhtml 模板和继承功能,我们可以将登录/注销功能添加到 MyBlog 应用程序中的每个页面。

  • 通过他们的 API 与外部服务交互,我们可以向用户发送电子邮件以确认他们的身份并重置他们的密码。使用此类服务有助于扩展 MyBlog 应用程序,并消除了配置、运行和维护电子邮件服务器的劳动。

  • 用户的授权是验证用户的另一面。授权信息决定了用户的角色——他们在登录到网络应用程序时可以做什么。

  • 使用 Flask 进行身份验证和授权,可以仅允许具有特定角色的已登录用户访问特定的 MyBlog 页面。允许用户进行系统级更改或由其他用户创建的更改的页面通常以这种方式进行保护。

10 持之以恒是好的:数据库

本章涵盖

  • 持久化数据

  • 数据库系统

  • 数据库结构

  • 使用 SQLAlchemy 模型数据

你在达到这一步的过程中展现出了极大的毅力,我希望这段旅程是令人满意的,并且引起了你的兴趣。尽管坚持做某件事是令人满意的,但这种毅力并不是本章要讨论的内容。

本章是关于随时间持久化应用程序数据。你不会永远运行你使用的应用程序,尽管计算机系统很稳定,但它们会定期关闭和重启。

想象一下使用一个复杂的电子表格,每次重启应用程序或打开电脑时都必须重新输入所有数据。即使有电脑巨大的处理能力,如果没有保存和恢复输入信息的方法,它几乎不会是一个有用的设备。

10.1 另一半

作为一名开发者,很容易将你正在创建的应用程序代码视为你努力的最重要的产品。但事实上,你那酷炫、关键的应用程序,以及所有精心设计的特性和功能,只是故事的一半。另一半,同样重要的是,你的应用程序帮助用户工作的数据。修改、转换以及对用户感兴趣的数据提供见解是应用程序工作的原材料。

10.1.1 随时间维护信息

文件系统将数据保存到独立于电力的存储介质中。大多数个人计算机系统在机械或固态驱动器上维护文件系统。这些存储设备由计算机的操作系统在其上分层构建文件系统结构。

文件系统提供了一个分层组织的机制来保存和从存储设备检索文件。就文件系统而言,一个文件是一系列数据字节,这些字节连接到一个文件名,该文件名存在于层次结构中的某个位置。此外,文件系统可以创建、修改和删除文件,并维护有关文件的家务元数据,例如读取、写入和可执行状态。

应用程序程序为文件中的数据赋予意义。例如,当照片查看应用程序打开一个 JPEG 图像文件时,用户会看到一个图片。照片应用程序可以解释文件的内容并生成预期的视觉结果。

如果用户用文本编辑器打开相同的 JPEG 图像文件,他们会看到一大块难以理解的数据。文件系统中的大多数文件都是这样的;它们的内容只有能够读取和解释它们的程序才能理解。

MyBlog 应用程序需要保存、修改和召回要显示给用户的内容。保存到文件系统的内容是应用程序可以理解的格式。MyBlog 应用程序已经使用数据库将注册用户信息保存到文件系统中。本章是一个从 MyBlog 应用程序出发的旁枝末节,以便更仔细地查看数据库。

10.2 访问数据

在直接深入研究数据库系统之前,让我们先谈谈一般的数据存储。为此,我们将使用产品订单,这是所有进行过在线购物的人都很熟悉的事情。稍后,我们将使用这个想法来说明在文件系统中存储数据时的一些问题。

首先,想象一个只向许多客户销售单一产品的在线商店。每位客户可能为该单一产品创建多个订单。为了使数据相对容易地展示在这本书的页面上,我们将保持信息量非常低——客户的姓名、他们的地址、产品名称和订单中的数量。

文件系统中数据的一种常见格式是逗号分隔值格式或 CSV。CSV 文件易于理解,并且具有可由计算机系统读取和访问的优点。

CSV 文件是一种简单的文本文件,其中每行文本都是一个以换行符结束的数据记录。逗号字符分隔每行文本中的数据元素。CSV 文件的文本第一行通常包含剩余文本文件中每行逗号分隔字段的名称。

CSV 文件不包含关于记录中每个元素数据类型的任何信息,一切只是文本。应用程序读取 CSV 文件并将每行以逗号分隔的文本拆分为文本数据字段。想象一家只向每位客户销售一种产品的公司,它可以将其所有客户信息和订单保存在单个 CSV 文件中。图 10.1 显示了数据可能保存的一种可能方式。

图片

图 10.1 包含所有公司订单及其订单数据的 CSV 文件结构

此 CSV 文件足以表示客户、他们的送货地址和他们的订单。第一个字段包含客户姓名,第二个字段包含他们的地址,第三个字段包含他们的邮政编码,第四个字段是产品名称,最后一个字段是订单中的产品数量。因为公司只销售一种产品,所以这可以工作。

即使在这个例子中,你也可能注意到一个潜在的问题。文件中有冗余数据。例如,对于 Joe 和 Mary 的订单,客户及其地址在每个单独的订单中多次表示。

如果客户 Joe 想开始使用他的全名 Joseph,那么多次存储相同的数据可能会成为一个问题。为了适应这种情况,公司必须更新与 Joe 相关的所有文件记录。这种更新容易出错,尤其是如果文件变得非常大。犯了一个错误,遗漏了一个或多个 Joe 记录,就会创建两组客户记录,一组是 Joe 的,另一组是 Joseph 的。

我们可以通过移除冗余,并为每个客户只保留一条记录,并在该记录中表示多个订单来解决这个问题。你可以创建包含数量的更多逗号分隔字段,但由于无法知道客户将创建多少订单,因此应用程序在读取 CSV 文件时很难知道期望多少个订单字段。

我们可以将多个产品和数量字段打包到单个订单字段中,但我们需要使用与逗号不同的分隔符来分隔值。因此,我们使用管道字符(竖线,|)来分隔订单,使用连字符字符(-)来分隔产品与数量。这样做可以让你在每条记录中维护多个订单,以便应用程序仍然可以解析文件中的文本行。

实施这个想法会在文件的一行中包含所有多个订单的数据,创建一个 CSV 文件。图 10.2 显示了具有这种结构变化的文件。订单信息仍然是逗号分隔的数据项,但需要特别解析以获取订单数据项。

图片

图 10.2 CSV 文件重构以减少数据冗余

这种实现减少了文件中的冗余以及其大小。减少冗余的代价是在读取和解释这个 CSV 文件中的数据时增加了处理量。应用程序将不得不在订单字段中解析逗号、管道和连字符字符分隔符的顺序。

假设我们的虚构公司决定销售多个产品,并且客户可以将订单发送到他们想要的任何地址。现在客户订单需要包含送货地址信息,该信息需要在字段内使用自己的分隔符以保持其独特性。图 10.3 说明了 CSV 文件中这个额外复杂性的示例。

图片

图 10.3 CSV 文件重构以适应多个产品和送货地址

添加更多分隔符可能可行,但在订单字段中解析多个数据项时,这会变得很荒谬。这种方法也不太适合扩展,因为添加更多产品会使订单字段变得更加复杂。

解决这个问题意味着要识别出需要存储的数据元素之间的逻辑划分。例如,一个客户可以向不同的送货地址发送多个订单,并且每个订单可以包含多个产品和数量。

每个客户可以有多个订单,但每个订单只与一个客户相关。同样,每个地址可以与多个订单相关,但每个订单只会发送到单个地址。

订单和产品稍微有点挑战性。一个订单可以包含多个产品,一个产品可以是多个订单的一部分。为了解决这个问题,我们发明了订单有一个项目的概念。项目与订单和产品相关联,提供了这种双向连接。项目还可能包含代表的产品数量。

我们可以根据这些逻辑线将数据分解成单独的 CSV 文件,本质上是在文本中添加了额外的分隔符。采取这一行动创建了五个 CSV 文件:客户、地址、产品、订单和项目。这五个 CSV 文件根据逻辑线分离数据。不幸的是,没有方法可以将客户连接到订单,订单连接到地址,或将项目连接到订单或产品。

为了连接数据,我们需要在文件中的数据行之间创建关系。我们可以通过为每个 CSV 文件中的每一行创建一个唯一的标识值来实现这一点。至少,行标识符只需要在单个 CSV 文件中的行之间是唯一的。

我们将在每行的开头添加另一列,并为每行分配一个递增的整数值。这个整数值唯一地标识了单个 CSV 文件中的每行数据,但 CSV 文件之间仍然没有关系。

要创建关系,我们将一个 CSV 文件中的一个记录的唯一标识符添加到另一个记录中,以指示两个记录之间的关系。我们将唯一标识符作为新值添加到与该客户相关的订单 CSV 文件的所有行中,以创建这种关系。这种关系被称为一对一。

我们还必须建立另一种关系。每个订单可以包含多个产品,每个产品可以与多个订单相关联。这种关系被称为多对多。从概念上讲,这是一种多对一关系与一对多关系的组合,并通过创建关系关联来实现。这就是前面提到的订单项概念是如何实现的。

要做到这一点,我们将创建一个包含每个项目的订单和产品 CSV 文件中唯一 ID 的项 CSV 文件。这样,一个订单可以连接到多个项目和多个产品。

图 10.4 显示了五个 CSV 文件、它们的内容和它们之间的关系。每个文件的数据行都以一个唯一的 ID 值作为首字段。这种结构显示“客户”、“产品”和“地址”文件没有冗余数据。它还显示“订单”和“项目”文件主要包含关系数据,除了“项目”文件中的唯一 ID 和“数量”值之外。

图片

图 10.4 CSV 文件消除了数据冗余,并允许有多个产品和地址。

由于 CSV 文件的结构和内容,我们的假设公司可以继续添加新的客户、新产品销售和新配送地址,而无需创建不可持续的重叠信息。examples/CH_10/examples/01/main.py程序中的程序使用这些信息为系统中的所有订单创建简单的发票 PDF 文件。

可在代码仓库中找到的程序通过将所有 CSV 文件读入内存并创建一个Transactions容器类来存储信息来工作。接下来,将Transactions类中的订单插入到 Jinja2 模板的交易信息字段中。生成的渲染 HTML 被转换为 PDF 文件,如图 10.5 所示。

这些想法及其实现是有效的,但存在重大限制。由于 CSV 文件被读入内存,客户、产品和订单的数量限制在应用程序可用的内存量。

图片

图 10.5 由examples/CH_10/examples/01/main.py程序生成的 PDF 发票

示例程序只有一个用途,即为系统中的所有订单创建一组订单发票。没有搜索订单、客户或产品的功能。我们假设的公司可能需要的任何其他用途,如搜索或报告,都需要更多的编程开发。

我们假设的公司可能希望有多个用户——包括客户和员工——与数据交互。协调多个访问必须由应用程序处理,以确保数据的一致性和完整性。如果多个应用程序访问 CSV 文件,这将增加另一个层次的复杂性来协调访问,确保所有应用程序中的数据同步和最新,并防止文件损坏。

此外,没有标准化的方式来使用 CSV 文件。CSV 文件很容易共享,但任何想要使用它们的人都需要详细了解文件的结构以及该结构所暗示的关系。如果他们想要修改数据内容,他们还必须维护这种结构。

对于任何用任何语言编写的应用程序,要处理数据,它必须专门处理 CSV 文件的目的。此外,CSV 文件结构的任何变化都要求软件进行更改,以便意识到这些变化。

我们假设的公司数据不足之处很多,都与数据的具体管理和详细的编程实现有关,即如何访问和维护数据。解决这个问题的方法之一是将数据移至数据库系统。

10.3 数据库系统

数据库系统允许您持久化数据以及这些数据之间的关系。一种常见类型的数据库是关系数据库管理系统,或 RDBMS。RDBMS 系统提供了创建、读取、更新和删除存储在其内部的表的功能。这些表与之前示例中使用的 CSV 文件所表示的两维表类似。

RDBMS 系统还具有通过连接跨越表边界的唯一 ID 值来创建和更新表之间关系的功能。与使用文件来持久化信息相比,数据库系统的一个优点是创建、更新和维护数据是由数据库而不是您的应用程序代码来处理的。

10.3.1 表

表代表数据库维护的数据。从概念上讲,数据库中的表是行和列的两维集合。

与之前展示的 CSV 文件类似,行是单个记录,列是行内的字段。与 CSV 文件中列由分隔符分隔的字符串不同,数据库表中的列具有定义好的数据类型。支持的数据类型取决于特定的数据库,但文本、整数、实数(十进制数)和 blob(二进制对象)的数据类型通常都支持。

数据库中的表可以图形化地表示为实体关系图(ERD)的一部分。而不是显示组成表的行和列,记录的列和数据类型信息被展示出来。

ERD 图标题是大写的表名。以下行包含关于每个列的特定信息,如列名和数据类型。PK 是主键(表的唯一标识符)的简称,表示customer_idCustomer表的主键。图 10.6 展示了Customer数据库表的视觉定义。

图片

图 10.6 显示字段名称和数据类型的客户表 ERD 图

订单 CSV 文件只包含唯一的 ID 值——一个用于行唯一 ID,另外两个用于连接客户和地址 CSV 文件行。图 10.7 中的新 FK 缩写是外键的简称。外键通过引用另一个表的主键来在两个表之间创建关系。

图片

图 10.7 Order 表包含一个主键和两个外键,这些外键引用其他表。

10.3.2 关系

存储和修改数据对于任何应用程序来说都至关重要,但数据之间的关系同样重要。我们虚构公司的更新后的 CSV 文件使您能够减少原始单个 CSV 文件的数据冗余。减少数据冗余是数据库规范化的重要方面之一。

将不同的数据分开到多个表中表明需要重新连接相关数据。RDBMS 系统使用主键和外键在多个表之间建立关系。

数据库表中的主键是行(记录)中的一个列,其值在整个表中是唯一的。通常,主键列的存在只是为了提供这个唯一的 ID 值,并且不包含关于记录本身的信息。

并非总是需要创建一个独立的主键字段。如果一个有用的数据列在整个表中是唯一的,那么该列可以成为主键。例如,假设有一个包含人员信息的表,其中包含他们的社会保障号码。在这种情况下,社会保障号码应该对表中的每个记录都是唯一的,并且可以成为主键。

提示:即使一个表中的列包含足够独特的数据可以用作主键,但通常创建一个不依赖于表中数据唯一性的独立主键列更容易且更具未来性。

大多数关系型数据库管理系统(RDBMS)系统都有在向表中插入新行时创建自动递增整数值的功能。这些值作为方便的主键值,确保在向表中插入新记录并增加值时在整个表中是唯一的。

UUID 主键

创建主键值的另一种选择是使用 UUID(通用唯一标识符)值。具有 UUID 值的键不仅在整个表中是唯一的,而且在所有数据库的所有表中都是唯一的。当数据库的结构和使用方式发生变化时,具有通用唯一主键可能会有所帮助。

随着条件和要求的变化,数据库结构会更新以满足这些需求。一个例子可能是合并两个表。在这种情况下,两个表的所有记录都应该存在于合并后的表中,并且每个记录仍然需要一个唯一的键。

如果两个源表都是使用自动递增的整数主键值创建的,那么合并表很可能会创建主键冲突。如果更改主键值以解决冲突,那么依赖于指向原始主键值的任何外键关系都将被破坏。解决这个问题需要相当多的努力。

然而,如果主键值是 UUID 值,那么合并表就不会产生冲突,因为 UUID 值的定义是它在任何地方都是唯一的。任何引用 UUID 主键的外键仍然与合并表一起工作。

使用基于 UUID 的主键的另一个有趣、可能较小的优势是“通过隐蔽性来提高安全性”。例如,在我们的虚构公司的一个网络应用程序中,他们可能有一个这样的 URL:

https:/ /imaginary_company.com/orders/2

有些人可能会猜测 URL 的最后部分是数据库中特定订单的自动递增主键。因此,他们可以更改 URL 中的最后一个值,查看系统中的每个订单,这可能会揭示比您希望更多的信息。

然而,如果数据库使用 UUID 主键值,URL 可能看起来像这样:

https:/ /imaginary_company.com/orders/1a99289c9de5482b90c3b45e20a60c20

现在 URL 中引用特定订单的最后一部分是一个没有连字符(-)的 UUID 值。现在基本上不可能有人猜测出一个有效的订单主键值。这并不是一个真正的安全步骤,只是使用 UUID 主键的一个副作用。

在数据库表中使用 UUID 作为主键会增加存储成本,因为 UUID 值比整数大。它们也可能以小的方式影响数据库的性能。在决定是否使用 UUID 值作为主键时,需要考虑成本与价值的问题。MyBlog 应用程序数据库使用 UUID 主键,这不仅是因为 MyBlog 功能的要求,而是为了展示实现方式。

一对多

在我们的假设公司中,客户和订单之间存在一对多关系。为了建立一对多关系,客户表中的唯一customer_id值也作为订单表中的数据列customer_id存在。customer_id值是关联到客户表的外键。任何数量的订单记录都可以有相同的customer_id外键值,从而创建一对多关系。

在表中创建外键时,提供给数据库引擎的定义部分是外键关联到哪个表。外键告诉数据库引擎存在关系,并帮助它提供使用该关系的功能。

多对多

我们假设的公司也建立了一种多对多的关系。建立多对多的关系更为复杂,从某些方面来说,可以将其视为一个与多对一关系相连的一对多关系。创建这种关系需要一张关联表,作为上述两个提到的部分之间的多个部分。

Item表创建了OrderProduct表之间的关联。Item表有一个指向Orderorder_id字段的外键,以及一个指向Productproduct_id字段的外键。

10.3.3 事务数据库

您将要创建的事务数据库为表和表中的列使用命名约定。表名使用单数名词来表示它们包含的内容:客户、产品等。

这种命名约定似乎不符合直觉,因为一张表有多个记录,名词的复数形式可能看起来更合适。然而,表是根据单行数据定义的,以及记录列的数据类型和含义。如何访问表可以返回一个或多个记录,但表本身是基于单条记录配置的。

此外,在命名表时使用复数可能会显得非常尴尬。例如,尝试在数据库表中定义一个人。复数形式将是名为 people 的表,可能有一个主键 people_id,这看起来并不优雅。将主键重命名为 person_id 会更好,但现在表名和主键之间存在认知上的脱节。

主键列的命名采用表名后缀 _id 的约定。尽管作为主键名称似乎冗余且冗长,但很明显,当在另一张表中使用时,具有此类名称的列是外键。

在本示例数据库中使用的命名约定绝非唯一可用的 definitive one。正如所提到的,给事物命名是困难的,数据库也不例外。关于如何在数据库中命名事物有许多约定,而正确的选择取决于你和你团队的舒适度。

图 10.8 使用常见的数据库 ERD 符号和表示法展示了事务数据库表的结构以及它们之间的关系。注意表之间的连接是如何从一张表的主键到另一张表的外键的。

连接线都是一对多关系的变体。Item 表的存在在 OrderItemProduct 表之间创建了一对多和多多关系。图 10.8 展示了公司数据库的完整 ERD 图。在 RDBMS 系统中创建、更新和交互数据使用的是大多数 RDBMS 系统提供的结构化查询语言(SQL)。

图片

图 10.8 我们想象中的公司事务数据库的完整 ERD

10.3.4 结构化查询语言:SQL

访问数据库的功能是标准化的,因此任何具有连接到数据库库的编程语言都可以使用它。这种标准化使得数据库比专有系统更容易在应用程序之间共享。

RDBMS 系统的许多标准化功能都是通过使用结构化查询语言(Structured Query Language,简称 SQL)向用户暴露的。SQL 与 RDBMS 系统作为声明性编程语言进行交互。声明性语言允许你表达你希望计算机系统做什么,而不是明确指示系统如何去做。

关于这一点的一种思考方式是去面包店要点蛋糕。你期望面包师会给你蛋糕,而不是要求你提供制作蛋糕的食谱。

获取数据

你将在本章的后面创建事务数据库,但在这里我会展示一些 SQL 查询来访问数据。这个 SQL 语句

SELECT * FROM customer;

返回以下结果:

customer_id  name
-----------  ----------
1            Joe
2            Mary
3            Sue

SQL 命令关键字是大写的,这只是一个约定。该语句要求数据库返回客户表中的所有行。星号 (*) 字符是一个通配符,用于获取每行返回的所有列。SQL 语句末尾的分号 (;) 字符是命令的终止符。

这个 SQL 查询要求按降序字母顺序排序客户数据库中的名称:

SELECT name FROM customer ORDER BY name DESC;

name
----------
Sue
Mary
Joe

SQL 还提供了转换和作用于数据的函数。下面的语句返回了客户数量:

SELECT COUNT(*) AS 'Total Customers' FROM customer;
Total Customers
---------------
3

COUNT 函数返回查询产生的总结果数,并将该值分配给别名—'Total Customers'—用作结果输出的列标题。

使用关系

因为事务数据库中的表代表了没有冗余的规范化数据,所以进行有趣的查询需要使用关系。在这个 SQL 语句中,返回了客户、他们所有用于订单的地址以及他们使用地址进行订单的次数,并按名称字母顺序排序:

SELECT c.name, a.street, a.zipcode, COUNT(c.name) AS 'Times Used'
FROM CUSTOMER c
JOIN 'order' o ON o.customer_id = c.customer_id 
JOIN address a ON a.address_id = o.address_id
GROUP BY a.street
ORDER BY c.name;

name        street      zipcode     Times Used
----------  ----------  ----------  ----------
Joe         12 Main St  12345       2
Mary        127 Margol  40322       1
Mary        41 Orange   40321       1
Sue         212 Grove   34213       1

在这里,SQL 语句跨越了多行,这没问题,因为语句只有在最后的终止字符(;)处才完成。和之前一样,只返回了表中的某些值,但这些值跨越了多个表。

初始时,查询从客户表开始,并将其分配给查询其他部分使用的别名简写,以减少歧义。为了获取每个订单使用的客户地址,查询需要使用客户、订单和地址表之间的关系。使用 JOIN 关键字实现这一点。它告诉数据库如何使用一个表的主键连接到另一个表的外键。

一个表的主键必须等于另一个表的对应行的外键,这样该行才能成为结果的一部分。跟随 ON 关键字的代码提供了包含数据的条件。

注意,在第一个 JOIN 语句中,'order' 表使用了单引号。单引号是必要的,因为单词 order 是一个 SQL 关键字;将其放在单引号中告诉 SQL 将 'order' 解释为表名而不是关键字。

文本 GROUP BY a.street 告诉 SQL 根据相同的街道值聚合结果。返回的结果表明了这一点。例如,Joe 有两个订单但使用了相同的地址。Mary 也有两个订单但每个订单使用了不同的地址。

我们虚构公司所有订单的发票所使用的 SQL 语句如下所示:

SELECT
c.name, a.street, a.zipcode, o.order_id, p.name, i.qty
FROM 'order' o
JOIN customer c ON c.customer_id = o.customer_id
JOIN address a ON a.address_id = o.address_id
JOIN item i ON o.order_id = i.order_id 
JOIN product p ON p.product_id = i.product_id

并返回以下结果:

name        street      zipcode     order_id    name        qty
----------  ----------  ----------  ----------  ----------  ----------
Joe         12 Main St  12345       1           widget      2
Joe         12 Main St  12345       1           thingy      3
Joe         12 Main St  12345       2           thingy      5
Mary        41 Orange   40321       3           widget      1
Mary        41 Orange   40321       3           thingy      9
Mary        127 Margol  40322       4           widget      7
Sue         212 Grove   34213       5           widget      3

这个 SQL 查询将事务数据库中的所有表连接起来,重新创建客户、订单、地址、产品和项目的冗余数据。

10.4 SQLite 作为数据库

在我们使用 SQLAlchemy 创建和使用数据库之前,让我们谈谈我们将用于事务数据库和 MyBlog 的特定数据库。在第八章中,我们使用了 SQLite 来持久化数据。对于事务数据库和 MyBlog 的其余开发,我们将继续使用 SQLite。

使用 SQLite 的决定基于几个考虑因素。SQLite 网站指出,如果你查看使用 SQLite 的系统和类型数量,SQLite 很可能是在全球范围内使用最广泛的数据库系统之一。它体积小、速度快、功能齐全,完全满足 MyBlog 应用程序的需求。

另一个,可能更相关的考虑因素是关于本书的,那就是 SQLite 作为进程内数据库运行,这意味着它像任何其他 Python 模块一样被拉入应用程序中。无需安装、配置和维护像 MySQL、PostgreSQL 或 SQL Server 这样的数据库服务器,就可以使用 MyBlog 开发过程进行构建和学习。

提示:数据库服务器如 MySQL、PostgreSQL 和 SQL Server 是功能强大的系统,可以轻松处理 MyBlog 应用程序的需求。然而,帮助读者启动这些系统需要时间和书籍空间。

最后,使用 SQLAlchemy 有助于抽象化底层数据库,让你能够专注于开发和数据库概念,而不是特定的数据库实现。如果你的 MyBlog 需求超过了 SQLite 可以为你提供的,由于 SQLAlchemy 提供的抽象,替换它为另一个数据库系统会更容易。

10.5 SQLAlchemy

SQLAlchemy 是一个流行的、强大的 Python 数据库访问库,它提供了一个对象关系映射器(ORM)。与 Python 一起工作的一个好处是它是一种面向对象的语言,Python 中的所有内容都是对象。将数据作为 Python 对象处理感觉更自然,也更符合 Python 风格。

Python 可以直接使用 SQL 访问数据库系统,这是一种可行的方案。大多数支持 SQL 的 Python 数据库库返回包含 SQL 语句结果的元组列表或字典。

展示如何获取创建订单发票所需数据的 SQL 语句显示了数据,但所有关系信息都丢失了。使用这些数据将需要软件来提取 OrderItemProduct 的层次关系。对象和平面数据之间的脱节被称为对象关系阻抗不匹配,这是 SQLAlchemy ORM 解决的问题。

10.5.1 优点

使用 SQLAlchemy 访问数据库允许你考虑对象和方法,而不是 SQL 和结果集。在大多数情况下,你不需要了解 SQL 就可以与底层数据库一起工作。相反,SQLAlchemy 构建必要的 SQL 语句,将结果数据映射到 Python 对象,反之亦然。

大多数关系型数据库管理系统(RDBMS)数据库支持 SQL;然而,它们通常在其实现中添加专有功能。除了特定用例之外,SQLAlchemy 抽象了这些差异,并在更高层次上工作。

SQLAlchemy 提供的另一个优点是保护您的应用程序免受 SQL 注入攻击。例如,如果您的应用程序将用户提供的信息添加到与数据库查询一起使用,则您的应用程序容易受到此类攻击。图 10.9 中的 XKCD 漫画很好地展示了这一点。

图片

图 10.9 妈妈的恶作剧(来源:xkcd.com,许可协议为 CC BY-NC 2.5)

10.6 建模数据库

将数据库连接到 SQLAlchemy 需要使用 Python 类定义来建模表结构。这些模型将表记录结构、字段数据类型以及表之间的关系映射到 Python 类定义。通过在类上调用方法创建这些 Python 类的实例,SQLAlchemy 将这些方法调用转换为 SQL 语句。

因为最终目标是使用 SQLAlchemy 与 MyBlog 应用程序一起使用,所以我们将使用 Flaskflask_sqlalchemy 模块来帮助定义类。flask_sqlalchemy 模块提供了便利功能和定义,但这里定义的类也可以仅使用 SQLAlchemy 模块来定义。

10.6.1 定义类

examples/CH_10/examples/02/main.py 程序导入了一个 models.py 模块。该 models.py 模块包含了创建数据库对象、建模表格以及用来自 examples/01 的 CSV 文件中的数据填充数据库的所有代码。

数据库连接

所有要定义的类都继承自 SQLAlchemy 提供的通用数据库对象。在定义类之前以这种方式创建数据库对象:

app = Flask(__name__)                                   ①
app.config["SQLALCHEMY_DATABASE_URI"] = 
➥"sqlite:/ //transaction.sqlite"                       ②
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False    ③
db = SQLAlchemy(app, session_options=
➥{"autoflush": False})                                 ④

① 创建 Flask 实例

② 配置 SQLAlchemy 使用 SQLite 并指定创建数据库文件的路径

③ 关闭了一个不必要的默认配置,该配置会生成警告

④ 创建 SQLAlchemy 数据库对象,在这种情况下,关闭了自动刷新,这有助于在提交()时使数据库操作更原子化

这段代码的目的是创建用于定义表模型的 db 对象实例。数据库本身存储在单个 transaction.sqlite 文件中。

建模表格

之前显示的事务数据库 ERD 是创建用于通过 SQLAlchemy 访问数据库所需的类定义的良好指南。类定义定义了要创建的数据库表、记录内的列名以及它们的数据类型。

还定义了一些字段,这些字段在数据库中不存在,但由 SQLAlchemy 在创建模型实例时创建和维护。这些额外字段在处理模型实例时提供了有用的功能,尤其是在处理表之间的关系时——例如,在Customer模型中定义的下一个orders属性。SQLAlchemy 维护了一个与Customer实例关联的所有订单的 Python 列表。让我们看看CustomerOrder类的定义:

class Customer(db.Model):                    ①
    __tablename__ = "customer"               ②
    customer_id = db.Column(db.Integer, 
    ➥primary_key=True)                      ③
    name = db.Column(db.String)              ④
    orders = db.relationship("Order", 
    ➥backref=db.backref("customer"))        ⑤

class Order(db.Model):                       ⑥
    __tablename__ = "order"                  ⑦
    order_id = db.Column(db.Integer, 
    ➥primary_key=True)                      ⑧
    customer_id = db.Column(db.Integer, 
    ➥db.ForeignKey("customer.customer_id")) ⑨
    address_id = db.Column(db.Integer, 
    ➥db.ForeignKey("address.address_id"))   ⑩

① 创建类,从 db 实例 Model 类继承

② 将类定义与客户数据库表关联

③ 创建客户 _id 列,类型为整数,并作为主键

④ 创建 name 列作为字符串

⑤ 创建属性 orders,将客户与其所有订单连接起来

⑥ 创建类,从 db 实例 Model 类继承

⑦ 将类定义与订单数据库表关联

⑧ 创建订单 _id 列,类型为整数,并作为主键

⑨ 创建 customer_id 作为整数,并作为外键指向客户表和 customer_id 字段

⑩ 创建 address_id 作为整数,并作为外键指向地址表和 address_id 字段

在这些类定义中发生了很多事情。通过从db.Model类继承,CustomerOrder类获得了 SQLAlchemy 功能,允许这些类与底层数据库交互。

customer_id列被定义为整数并作为主键。通过这样做,每次向数据库添加新的Customer实例时,customer_id字段都由一个自动递增的函数初始化。对于Order类中的order_id字段也是如此。

name列是一个简单的字符串,映射到最佳支持 Python 字符串类型变量的数据库类型。因为 SQLite 是底层数据库,所以该类型是TEXT

Customer类的属性orders很有趣且很有用。它根本不在数据库客户表中定义一个列。相反,它创建了一个由 SQLAlchemy 维护的属性,该属性作为开发人员可用。

orders属性使用Order类中创建的customer_id外键建立的关系。Customer实例有一个orders属性,它是与客户关联的Order实例的 Python 列表。看起来奇怪的backref参数传递给db.relationship(...)Order类定义中创建了一个由 SQLAlchemy 维护的属性,名为customer,它指向与订单相关的Customer实例。图 10.10 展示了在客户实例中 SQLAlchemy 维护的订单列表的视觉表示。

图 10.10 客户与其订单之间的一对多关系是一个 Python 列表。

当你有一个Customer实例时,orders属性允许你编写如下 Python 代码:

print(f"Customer {customer.name} has these order number")
for order in customer.orders:
    print(f"Order number: {order.order_id}")

当打印订单发票时,SQLAlchemy 创建和维护的关系和属性非常有用。其余的 SQLAlchemy 模型定义如下:

class Address(db.Model):
    __tablename__ = "address"
    address_id = db.Column(db.Integer, primary_key=True)
    street = db.Column(db.String)
    zipcode = db.Column(db.String)
    orders = db.relationship("Order", backref=db.backref("address"))

class Product(db.Model):
    __tablename__ = "product"
    product_id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String)

class Item(db.Model):                               ①
    __tablename__ = "item"                          ②
    order_id = db.Column(db.Integer, 
    ➥db.ForeignKey("order.order_id"), 
    ➥primary_key=True)                             ③
    product_id = db.Column(db.Integer, 
    ➥db.ForeignKey("product.product_id"), 
    ➥primary_key=True)                             ④
    qty = db.Column(db.Integer)                     ⑤
    order = db.relationship("Order", 
    ➥backref=db.backref("items"))                  ⑥
    product = db.relationship("Product")            ⑦

① 创建类,从db实例Model类继承

② 将类与项目数据库表关联

③ 创建order_id作为整数,并将其作为order表和order_id字段的ForeignKey

④ 创建product_id作为整数,并将其作为product表的ForeignKey以及product_id字段

⑤ 创建跟踪此项目产品数量的qty字段

⑥ 创建仅实例属性顺序,将一个订单连接到这个项目

⑦ 创建仅实例属性product,将一个产品连接到这个项目

Item类定义创建了订单、该订单中的项目以及与项目相关产品的多对多关联关系。

10.7 创建和使用数据库

一旦定义了 SQLAlchemy 模型,就可以创建数据库。以下 Python 代码行创建数据库:

db.create_all()

如果之前定义的transaction.sqlite SQLite 数据库文件不存在,它将使用模型定义的表结构创建,并且那些表将是空的。然而,如果transaction.sqlite数据库文件已经存在,代码将不会重新创建它;它只会连接到它。

重要的是要认识到,如果数据库已经存在,对 SQLAlchemy 模型所做的任何更改都不会出现在数据库中。您可以删除并重新创建数据库,它将匹配更新的模型,这在这种情况下是可以接受的,但在大多数情况下是不合理的操作。

提示:对于现有的数据库,您需要使用 SQL 语句或其他数据库迁移工具来修改数据库以匹配更新的 SQLAlchemy 模型。作为一名工作开发者,从头创建数据库并不常见。更常见的是修改现有数据库以添加新功能和功能。Python 工具如 Alembic([pypi.org/project/alembic/](https://pypi.org/project/alembic/))对于此类活动与 SQLAlchemy 非常有用。

10.7.1 添加数据

即使从头开始创建和填充数据库对于开发者来说不是日常活动,我们仍将在examples/CH_10/examples/02/models.py中查看,以了解 SQLAlchemy 如何创建和插入数据库表记录。examples/CH_10/examples/02/main.py中的程序目标是复制examples/CH_10/examples/01/main.py的行为,但使用数据库而不是 CSV 表。这样做意味着解析 CSV 文件并使用 SQLAlchemy 将数据插入数据库。models.py模块包含 SQLAlchemy 模型和创建数据库的语句。它还有一个自定义函数,用于读取 CSV 文件并将它们加载到数据库表中,如下所示:

def load_database():
    customers = CsvData("customer.csv")                    ①
    addresses = CsvData("address.csv")                     ①
    orders = CsvData("order.csv")                          ①
    products = CsvData("product.csv")                      ①
    items = CsvData("item.csv")                            ①

    with session_manager() as session:                     ②
        # create the customers
        for customer in customers.data.values():           ③
            session.add(Customer(                          ③
                name=customer.get("name")                  ③
            ))                                             ③

        # create addresses
        for address in addresses.data.values():            ④
            session.add(Address(                           ④
                street=address.get("street"),              ④
                zipcode=address.get("zipcode")             ④
            ))                                             ④
        # create products
        for product in products.data.values():             ⑤
            session.add(Product(                           ⑤
                name=product.get("name")                   ⑤
            ))                                             ⑤
        # commit these items
        session.commit()                                   ⑥

        # build a map of orders
        orders_map = {str(index): Order() 
        ➥for index, order in enumerate(
        ➥orders.data.values(), start=1)}                  ⑦

        # build the orders and items
        for item in items.data.values():                   ⑧
            # get the order_id and order associated 
            ➥with this item
            order_id = item.get("order_id")                ⑨
            order = orders_map.get(order_id)               ⑨

            # get the customer, address and product associated with the item
            customer_id = orders.data
            ➥.get(order_id)
            ➥.get("customer_id")                          ⑩
            customer = session.query(Customer)             ⑩
            ➥.filter(Customer.customer_id == customer_id) ⑩
            ➥.one_or_none()                               ⑩
            address_id = orders.data                       ⑪
            ➥.get(order_id).get("address_id")             ⑪
            address = session.query(Address)               ⑪
            ➥.filter(Address.address_id == address_id)    ⑪
            ➥.one_or_none()                               ⑪

            if order.customer is None:                     ⑫
                order.customer = customer                  ⑫
            if order.address is None:                      ⑫
                order.address = address                    ⑫

            # create an item with it's many-to-many associations
            product_id = item.get("product_id")            ⑬
            product = session.query(Product)               ⑬
            ➥.filter(Product.product_id == product_id)    ⑬
            ➥.one_or_none()                               ⑬
            new_item = Item(                               ⑬
                qty=item.get("qty")                        ⑬
            )                                              ⑬
            new_item.product = product                     ⑬
            order.items.append(new_item)                   ⑬

        # add the populated orders to the 
        ➥session and database
        for order in orders_map.values():                  ⑭
            session.add(order)                             ⑭
        session.commit()                                   ⑭

① 将所有 CSV 文件加载到变量中,这些变量是字典的行

② 使用上下文管理器来控制对象何时(或不)提交到数据库

③ 创建客户实例并将它们添加到数据库会话中

④ 创建地址实例并将它们添加到数据库会话中

⑤ 创建产品实例并将它们添加到数据库会话中

⑥ 将会话提交到数据库,为该会话中的所有对象分配唯一的 ID 并将它们持久化到数据库中

⑦ 创建一个订单映射以帮助连接订单、项目、客户和产品

⑧ 遍历项目

⑨ 找到与当前项目相关的订单

⑩ 找到与找到的订单相关的客户。第二个语句是一个 SQLAlchemy 查询,用于获取客户实例

⑪ 找到与找到的订单相关的地址。第二个语句是一个 SQLAlchemy 查询,用于获取地址实例。

⑫ 仅当客户和地址不存在时,才将客户和地址分配给订单

⑬ 找到与项目相关联的产品实例,将其分配给项目,然后将项目追加到订单中

⑭ 将所有初始化的订单添加到会话中,并将会话提交到数据库,以持久化订单和项目

这段代码中发生了很多事情。核心是读取 CSV 文件并使用数据来创建相应的 SQLAlchemy 模型的实例。然后使用 SQLAlchemy 维护的属性来开发实例之间的关系。

创建 customeraddressproduct 实例,然后使用 session.commit() 语句将它们持久化到数据库中,为每条记录生成唯一的 ID 主键值。这些主键值在创建订单及其相关项目时用于建立关系。

10.7.2 使用数据

examples/CH_10/examples/02/main.py 程序演示了使用 transaction.sqlite 数据库为所有订单生成发票 PDF 文件:

import os
import csv
import sqlite3
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
from models import load_database, Order, 
➥session_manager                                         ①

def create_invoice(order):                                ②
    """Create the PDF invoice for the order

    Args:
        info (dict): The info information to generate the invoice with
    """
    invoice_filename = f"invoice_{order.order_id}.pdf"

    # delete existing order invoice file if exists
    if os.path.exists(invoice_filename):
        os.remove(invoice_filename)

    # set up Jinja2 to generate the HTML and then the PDF file
    path = Path(__file__).parent
    env = Environment(loader=FileSystemLoader(Path(path)))
    template = env.get_template("invoice_template.jinja")
    html_out = template.render(order=order)               ③
    HTML(string=html_out).write_pdf(
        invoice_filename, 
        stylesheets=[
            "page.css", 
            "bootstrap.css",
        ]
    )

# load the database
load_database()                                           ④

# generate an invoice file for all the orders
with session_manager() as session:                        ⑤
    for order in session.query(Order).all():              ⑤
        create_invoice(order)                             ⑤

① 从 models.py 模块导入功能。注意,只使用了 SQLAlchemy 的 Order 定义。

② 将单个订单实例传递给 create_invoice 函数

③ 将单个订单实例传递给 Jinja 模板作为上下文参数

④ 调用 models.py 模块中定义的 load_database 函数以填充数据库

使用数据库会话,查询数据库中的所有订单,遍历它们,并将单个实例传递给 create_invoice 函数

该程序的大部分工作是从传递给它的单个 SQLAlchemy Order 实例创建发票 PDF 文件。由于模型中建立的关系,打印发票所需的一切都与 Order 实例相关联。Jinja 模板使用订单上下文参数来填充模板的动态部分:

<html lang="en">
  <head>
    <title>Invoice</title>
  </head>
  <body>
    <div class="container border border-dark border-2 rounded-2">
      <div class="container border border-dark mt-3 mb-3">
        <h3>Invoice</h3>
        Customer: {{order.customer.name}}<br />               ①
        Street: {{order.address.street}}<br />                ②
        Zip Code: {{order.address.zipcode}}<br />             ②
        Order Number: {{order.order_id}}<br />                ③
      </div>
      <table class="table table-striped table-bordered caption-top">
        <caption>
            Order Items
        </caption>
        <thead>
          <tr>
            <th>Item Number</th>
            <th>Product Name</th>
            <th>Quantity</th>
          </tr>
        </thead>
        <tbody>
          {% for item in order.items %}                       ④
          <tr>                                                ④
            <td>{{loop.index}}                                ④
            <td>{{item.product.name}}                         ④
            <td>{{item.qty}}                                  ④
          </tr>                                               ④
          {% endfor %}                                        ④
        </tbody>
      </table>
    </div>
  </body>
</html>

① 使用订单实例的客户属性来获取客户的名字

② 使用订单实例的地址属性来获取订单发货的地址

③ 打印出订单的唯一 ID 值

④ 使用订单实例的项目集合以表格形式打印出项目信息

对于具有表之间一对一关系的表创建模型,SQLAlchemy 提供了以分层方式访问数据而不是以扁平、二维方式访问数据的属性。这些模型以及从它们创建的对象实例允许你以 Pythonic 的方式考虑数据,而不是自己管理关系,并在列表和字典之间跳转。

我们假设的公司及其简单的数据需求使我们能够看到数据库如何极大地增强仅仅持续数据的能力。通过向数据结构添加关系,相关数据的存储大大简化,功能也得到了提升。

10.8 总结性思考

你已经从微观的角度了解了数据库系统,以了解它们的功能、工作方式以及为什么它们是有用的。数据库系统为你提供了访问强大功能的方法,而使用 SQLAlchemy 与这些系统交互让你能够以 Pythonic 的方式思考。此外,使用像 SQLAlchemy 这样的工具可以帮助你保持在单个 Python 领域内,而不是在 SQL 和 Python 领域之间进行心理上下文切换。当然,在后者中工作也是可能的,但前者更有效率。

摘要

  • 随着时间的推移持续数据通常意味着将其保存到长期存储设备中,例如连接到计算机的硬盘或固态驱动器系统。文件提供了一个简单的解决方案,但存在一些缺点。

  • 数据库系统为应用程序提供了一种以结构化方式持续数据并建立数据之间关系的方法。关系数据库管理系统(RDBMS)是提供结构化、相关数据存储的应用程序。

  • 数据存储对任何应用程序都很重要,但数据组之间的关系同样重要。对一对一和多对多关系(以及其他关系)的支持极大地提高了现代数据库系统的实用性。

  • Python SQLAlchemy 模块在数据库系统中具有行数据的表与 Python 应用程序中更面向对象访问方法之间架起了一座桥梁。

11 我有一些话要说

本章涵盖

  • 创建和修改 MyBlog 内容帖子

  • 创建和显示内容帖子

  • 创建和显示帖子评论

  • 通知用户关于帖子和评论的信息

  • 处理错误

终于,我们到了向 MyBlog 应用程序添加内容的时候了。到目前为止,我们为构建应用程序所做的一切都是为了创建基础设施,以便将内容发布到博客上。

你已经创建了一个数据库来持久化存储 MyBlog 应用程序随时间管理的信息。此外,你创建了具有角色的用户,以便这些用户可以注册并使用应用程序。你还使用 Flask Blueprints 对应用程序进行模块化,以帮助管理应用程序不断增长复杂性,这为应用程序增加了结构和控制,帮助你和你的用户在 MyBlog 应用程序上创建和管理内容。

让我们制定一个命名约定,以帮助我们讨论内容。内容是用户编写的吸引人的信息,其他用户阅读并对其发表评论。内容有一个作者、创建时间戳、更新时间戳和标题。作者、时间戳和标题都与内容及其元数据相关联。我们将将这些信息汇总到我们称之为“帖子”的东西中,就像在公告板上,用户“发布”他们希望其他人阅读的信息。

11.1 MyBlog 帖子

A MyBlog Post 对象将内容及其所有相关信息汇总在一个地方。Post 对象持久化存储在数据库中,以便访问、搜索和管理。

在深入创建内容之前,我想谈谈 MyBlog 设计目标之一——内容的展示。MyBlog 应用程序展示的内容以纯文本形式存储。然而,该文本可以包含 Markdown 语法以增强内容的展示。Markdown 是一种在展示过程中包含纯 ASCII 字符的文本内容的方式,用于将文本以粗体或斜体形式渲染,在视觉内容中包含标题部分,并生成许多其他功能。

为了实现这一点,我们将使用由米格尔·格林伯格创建的 Flask-PageDown 模块。此模块负责正确展示 MyBlog Markdown 内容。它还提供了使用 Markdown 文本并交互式预览其渲染方式的功能。您可以在以下位置找到有关 Flask-PageDown 模块的信息:blog.miguelgrinberg.com/post/flask-pagedown-markdown-editor-extension-for-flask-wtf.

小贴士 米格尔·格林伯格是一位软件工程师,他在自己的博客上撰写关于 Python 和 Flask 的文章,并发布了对经验丰富的 Python 开发者有用的书籍和模块。他的工作非常值得一看。

11.1.1 模型数据库

Post 对象是另一个 SQLAlchemy 类,它定义了持久化信息的帖子数据库表的结构。因为注册 MyBlog 应用的用户可以创建多个帖子,所以用户和帖子之间存在一对一或多对一的关系。图 11.1 是现有 User 表和新的 Post 表之间的 ERD(实体关系图)。接下来,我们将创建 MyBlog Python 应用程序用于与数据库和 Post 表通信的 SQLAlchemy 模型。

SQLAlchemy Post

你将创建 Post 类来模拟 Post 表,就像你在 MyBlog 应用程序中对其他模型所做的那样。Post 类从 db.Model 类继承,以赋予其 SQLAlchemy 功能。它还使用 get_uuid() 函数来创建 UUID 主键值。

图 11.1 ERD 显示了用户与其帖子之间的一对零或一对多关系。

这里是来自 examples/CH_11/examples/01/app/models.pyPost 类定义:

class Post(db.Model):
    __tablename__ = "post"                                           ①
    post_uid = db.Column(db.String, 
    ➥primary_key=True, default=get_uuid)                            ②
    user_uid = db.Column(db.String, 
    ➥db.ForeignKey("user.user_uid"), nullable=False, index=True)    ③
    title = db.Column(db.String)
    content = db.Column(db.String)
    active = db.Column(db.Boolean, nullable=False, default=True)
    created = db.Column(db.DateTime, 
    ➥nullable=False, default=datetime.now(tz=timezone.utc))         ④
    updated = db.Column(db.DateTime, nullable=False, default=datetime.now(
        tz=timezone.utc), onupdate=datetime.now(tz=timezone.utc))    ⑤

① 将模型连接到 ERD 图中定义的名为 Post 的表

② 使用 get_uuid() 函数为表创建主键

③ 创建与用户表和用户 uid 值的外键关系,并为该列创建索引以加快查询速度

④ 标记创建字段,以便数据库在记录创建时自动添加 UTC 时间戳

⑤ 标记更新字段,以便数据库在记录更新时自动更新 UTC 时间戳

Post 类创建了一个结构来管理内容、用户(作者)、标题和审计信息。因为我们已经在 Post 类和 User 类之间建立了关系,所以我们需要修改 User 类以利用这种关系:

class User(UserMixin, db.Model):
    __tablename__ = "user"
    user_uid = db.Column(db.String, primary_key=True, default=get_uuid)
    role_uid = db.Column(db.String, 
    ➥db.ForeignKey("role.role_uid"), index=True, nullable=False)
    first_name = db.Column(db.String, nullable=False)
    last_name = db.Column(db.String, nullable=False)
    email = db.Column(db.String, nullable=False, unique=True, index=True)
    hashed_password = db.Column("password", db.String, nullable=False)
    \1 
    ➥backref=db.backref("user", lazy="joined"))                  ①
    active = db.Column(db.Boolean, nullable=False, default=True)
    confirmed = db.Column(db.Boolean, default=False)
    created = db.Column(db.DateTime, 
    ➥nullable=False, 
    ➥default=datetime.now(tz=timezone.utc))
    updated = db.Column(db.DateTime, nullable=False, default=datetime.now(
        tz=timezone.utc), onupdate=datetime.now(tz=timezone.utc))

① 新增的 posts 属性,它是与 Post 类的关系

新增的 User 类的 posts 属性在 User 表中创建了一个与 Post 类的关联。posts 属性在 User 表中不存在;它是由 SQLAlchemy 创建和管理的。当响应数据库查询创建 User 类的实例时,会创建 posts 属性。

使用 posts 复数是有意为之,因为 db.relationship() 定义将单个用户连接到许多可能的帖子。posts 属性是一个包含零个或多个可能帖子的 Python 列表,用户可以创建这些帖子。

db.relationship() 定义的第一个参数是 "Post"。它是一个字符串,因为 Post 类是在 User 类定义之后定义的,使用字符串会导致 SQLAlchemy 在运行时解析关系。它通过 Post 类中定义的 ForeignKey 作为 user.user_uid 属性的 ForeignKey 来连接到 Post 类。

db.relationship() 的第二个参数是 backref=db.backref("user", lazy="joined"),它处理两个元素:

  1. Post类的实例上创建一个名为"user"的属性,它引用父用户。SQLAlchemy 维护"user"属性。

  2. 表明UserPosts之间的关系应在相同的 select 语句中使用"joined",以便与用户关联的帖子可以立即可用,而不是需要额外的查询来检索它们。

随着新的Post和更新的User类定义,我们可以以一致、结构化的方式持久化内容并检索它。现在我们需要构建一个创建和显示该内容的系统。

11.2 方向改变

在进一步进行之前,我应该指出,本书的剩余部分将改变其展示模式。之前的章节包含了大量的或完整的代码示例,因为我觉得它们有助于看到新概念的实施。还有新的概念要介绍,但它们也包括您之前见过的样板代码块。

在书中呈现代码块会使阅读变得枯燥,并且不利于章节的目标,即介绍新想法及其实现方式。因此,我们不会审查大量的代码部分,而是将重点放在代码提供的内容上,并参考此书附带的 GitHub 仓库中的具体代码。

11.3 内容蓝图

在本章的开头,我们讨论了拥有基础设施的好处。我们将利用这个基础设施——从认知上讲是因为您所知道的内容,从实际意义上讲是因为您为 MyBlog 应用程序构建的结构。

您将构建一个名为Content的蓝图来管理 MyBlog 的内容。在各个方面,这都类似于Auth蓝图;它将功能隔离到单独的模块中,使得在内容域的上下文中思考和工作的更容易。这样做就是实践了关注点分离的理念。

就像为auth蓝图所做的那样,您将在应用程序包下添加一个名为content的新包目录。此目录包含一个__init__.py文件,其中包含以下代码:

from flask import Blueprint

content_bp = Blueprint(
    "content_bp", __name__,
    static_folder="static",
    static_url_path="/content/static",
    template_folder="templates"
)

from . import content

这段代码在概念上与您之前为auth/.__init__.py文件所做的是相同的。它创建了一个名为content_bp的蓝图实例,并配置它具有独立的statictemplates文件夹。它还导入了一个名为content的模块,其中包含管理用户内容的处理程序。现在您已经为 MyBlog 的内容帖子获得了蓝图命名空间,让我们用它来展示内容。

11.4 显示和创建帖子

MyBlog 应用程序需要一个 Python 处理程序来拦截,以及一个处理内容 URL 调用以在浏览器中显示内容的函数。它还需要模板来将帖子渲染为 HTML 页面。

11.4.1 显示处理程序

内容处理程序需要访问 MyBlog 应用程序的其他部分,它通过在 examples/CH_11/examples/01/app/content/content.py 模块中导入所需的模块和实例来实现。内容模块中的第一个处理程序用于 URL "/blog_posts",并处理两个函数:

  • 首先,如果 URL 通过带有 actionNone 的查询字符串被调用,它将显示 MyBlog 应用程序中的所有内容帖子。

  • 如果 URL 通过带有 action=create 的查询字符串被调用,它向登录用户展示一个网页表单,用于创建帖子内容。当表单提交时,该内容可以保存到数据库中。

blog_posts() 处理程序是一个简单的分派函数,根据 URL 的调用方式将调用两个其他函数。这两个操作可以在一个函数中处理,但那样会更复杂且难以理解。通过将行为拆分为两个函数来减少复杂性。

第一个分派函数是 blog_posts_display(),负责将 MyBlog 博客的所有帖子渲染到浏览器页面。blog_posts_display() 函数通过以下步骤显示帖子列表:

  1. 从请求中获取搜索字符串。

  2. 开始数据库会话上下文管理器。

  3. 从请求中获取当前页面。

  4. 创建一个查询到数据库的内容帖子,按更新时间戳降序排序。

  5. 有条件地向查询添加过滤器以控制用户是否可以看到活动帖子或所有帖子。这允许具有编辑器或管理员权限的用户看到活动和非活动帖子。

  6. 有条件地向查询添加过滤器以仅返回包含搜索词的帖子。

  7. 根据查询获取相关帖子。

  8. 将帖子列表渲染到浏览器显示。

11.4.2 显示模板

内容处理程序负责收集传递给模板的预期数据。然后,examples/CH_11/examples/01/app/content/templates/posts.xhtml 模板可以被渲染并发送到浏览器。

auth 蓝图中的模板一样,内容模板继承自系统范围内的 base.xhtml 模板文件,并从那里构建内容块。模板遍历内容处理程序传递的帖子列表(如果有)。模板代码格式化内容的一百字符摘录,并将其渲染为 Bootstrap 卡片。如果你在 examples/CH_11/examples/01 中运行 MyBlog 应用程序,并且数据库中有示例内容,系统将渲染内容帖子页面。

注意博客帖子是按最新顺序显示的。这是因为查询使用更新时间戳降序排序结果。因为“第二篇帖子”是在“这是第一篇帖子”之后创建的,所以在渲染的显示中它出现在第一位。

由于在截图(如图 11.2 所示)捕获时,我使用的数据库中只有两个帖子,因此不需要分页显示,只显示单个页面链接“1”。处理程序中的render_pagination()宏创建了这些页面链接。每页要显示的博客帖子数量由settings.toml文件中的配置变量blog_posts_per_page控制。

图 11.2 显示第一个和第二个帖子渲染内容的浏览器页面

11.5 创建帖子

创建 MyBlog 内容帖子需要一个 URL 处理程序、一个表单以及一个用户输入内容并预览的模板。一旦用户对其创建的内容满意,他们就可以将其保存到数据库中。

11.5.1 创建处理程序

blog_posts()分发处理程序中的第二个函数是blog_posts_create()函数,它渲染一个基于表单的页面,注册用户可以通过该页面创建和保存博客帖子内容。blog_posts_create()处理程序函数按照以下步骤从表单中获取内容并将其保存到数据库:

  1. 创建PostForm表单处理类的实例。

  2. 检查是否点击了表单取消按钮,如果是,则重定向到主页。

  3. 如果点击了提交按钮,则验证表单;如果没有点击,则渲染空表单。

  4. 如果表单已提交并且通过验证,则打开数据库会话上下文管理器。

  5. 创建Post SQLAlchemy 模型类的实例,传入表单内容。

  6. Post实例添加到数据库会话中。

  7. 将会话提交到数据库。

  8. 通知用户帖子已创建。

  9. 将用户重定向到新创建的帖子。

11.5.2 创建表单

由于帖子是通过表单创建的,处理程序需要访问一个类定义来管理该表单。PostForm类位于examples/CH_11/examples/01/app/content/forms.py文件中。

PostForm类继承自FlaskForm,就像在auth蓝图中使用的形式一样。创建帖子内容表单有四个元素:

  • title—一个StringField,用于包含标题文本

  • content—一个PageDownField,用于包含文本,当内容被渲染时可以使用 Markdown 语法进行显示

  • post_create—提交表单到处理程序的SubmitField

  • cancel—另一个被处理程序拦截的SubmitField项,用于取消任何操作并将用户返回到主页

render_kw参数添加到表单中的所有字段。render_kw参数是一个字典,当元素被渲染到浏览器显示时,它为元素添加额外的 HTML 属性。

render_kw字典中的tabindex键控制当在键盘上按下 TAB 或 ALT-TAB 键时,光标从元素到元素移动的顺序。render_kw字典中的autofocus键控制表单渲染时哪个元素具有光标焦点。tabindexautofocus键为表单添加了可用性功能,以帮助用户减少导航和使用表单所需的点击次数。

11.5.3 创建模板

创建帖子模板通过帖子创建处理程序与PostForm类连接。处理程序使用模板文件examples/CH_11/examples/01/app/content/templates/post_create.xhtml来渲染表单并将其发送到浏览器。表单的交互式 Markdown 行为通过创建一个类型为PageDownField的表单字段并在页面上渲染它来处理。

如果你从examples/CH_11/examples/01/目录运行应用程序,登录并导航到创建帖子,你将看到一个帖子内容创建表单。图 11.3 中的截图显示了此显示。表单有两个文本输入字段,标题和内容。屏幕的下半部分交互式地表示浏览器将如何渲染内容字段中的文本。我输入了一些包含 Markdown 标题的文本,以显示 Markdown 是如何由Flask-PageDown模块显示的。

图片

图 11.3 博客帖子内容创建表单使用 Markdown 来格式化内容。

当用户在内容输入字段中键入时,系统将交互式地更新显示的下半部分以渲染该内容。这在创建 Markdown 内容时非常有用,类似的行为在 Stackoverflow 和 GitHub 等网站上也可以看到。现在用户可以创建 MyBlog 内容,让我们给他们一个编辑这些内容以更新帖子的方式。

11.6 显示和编辑帖子

你已经开发了显示多个帖子并创建新帖子的基础设施。现在你需要构建显示和编辑单个帖子的系统。

11.6.1 显示处理程序

在上一节中,你处理了向浏览器显示多个简短帖子。你还提供了创建新帖子的能力。现在我们需要添加显示单个帖子的支持。如果你回顾一下渲染 MyBlog 帖子列表的模板,你会看到每个帖子都被一个 HTML 超链接锚点标签<a...>包裹,该标签生成一个"/blog_posts/{post_uid}" URL。此链接使用唯一的post_uid值将用户导航到单个帖子。

与处理多个帖子的初始处理程序一样,单个帖子通过一个调度例程来处理,以处理带有或没有查询字符串的 HTTP GET请求。blog_post()处理程序是另一个这样的调度函数,将任务委托给另外两个函数——一个用于显示单个帖子,另一个用于编辑单个帖子。

第一个函数是 blog_post_display(post_uid),它根据链接中传递的 post_uid 参数渲染单个 MyBlog 帖子。该函数采取以下步骤从数据库获取帖子内容并在浏览器中显示:

  1. 开始数据库会话上下文管理器。

  2. 创建初始数据库查询以获取具有 post_uid 值的帖子。该查询还与 user 表执行 JOIN 操作,以获取与帖子相关的用户信息。

  3. 根据用户权限修改查询,以查看所有帖子或仅查看活跃帖子。

  4. 执行数据库查询。

  5. 如果查询没有返回任何帖子,则使用 NOT FOUND 错误终止。

  6. 使用相关单个帖子模板渲染帖子。

11.6.2 显示模板

blog_post() 内容处理程序使用通过 URL 传递的 post_uid 值作为路径参数从数据库获取内容帖子。接下来,将 post 信息传递给显示模板 examples/CH_11/examples/01/app/content/templates/post.xhtml。该模板负责将其接收到的数据渲染为 HTML 页面,以便在用户的浏览器中显示。

模板条件性地在页面的右上角渲染活动徽章,以指示帖子的活动/非活动状态。这对于 MyBlog 管理员、编辑以及帖子的作者都适用。此外,模板条件性地渲染更新按钮,预期导航到编辑系统。图 11.4 展示了渲染显示的截图。

图片

图 11.4 单个内容帖子显示,为帖子作者渲染

显示的创建和更新字段显示了帖子作者创建和最后更新帖子的时间戳。这两个时间戳都显示在登录用户的本地时区。如果您还记得,MyBlog 数据库中所有表的模型都有 createdupdated 字段,并且这些字段自动填充 UTC 时间戳。那么系统是如何呈现本地时区时间戳的呢?如果您查看 post.xhtml 模板,您将看到以下代码片段:

<li class="list-group-item">
    Created: {{ post.created | format_datetime | safe }}
</li>
<li class="list-group-item">
    Updated: {{ post.updated | format_datetime | safe }}
</li>

这几行代码将 createdupdated 时间戳渲染为 HTML 无序列表项。请注意,时间戳数据被导入 format_datetime,然后导入 safe。之前,在 MyBlog 模板中已使用 safe 过滤器,但 format_datetime 是添加到 app/__init__.py 模块的新过滤函数:

@ app.template_filter()
def format_datetime(value, format="%Y-%m-%d %H:%M:%S"):
    value_with_timezone = value.replace(tzinfo=timezone.utc)
 tz = pytz.timezone(session.get("timezone_info", {}).get("timeZone", "US/Eastern"))
 local_now = value_with_timezone.astimezone(tz)
 return local_now.strftime(format)

装饰器将format_datetime()函数作为过滤器添加到模板引擎中,使其在之前显示的 HTML 模板片段中可用。因为它是一个过滤器管道的一部分,它接受模板管道中它之前的任何值的参数——在这个例子中,是一个协调世界时(UTC)时间戳。然后它使用用户会话中的timezone_info来创建本地时区的时间戳。然后,这个本地时间戳被格式化为字符串并返回。

timezone_info数据来自用户的会话信息。它是如何到达那里的?examples/CH_11/examples/01/app/auth/auth.py模块的login()函数已被修改,向用户的会话中添加时区信息。当用户登录时,以下代码行将时区信息添加到他们的会话中:

session["timezone_info"] = json.loads(form.timezone_info.data)

这行代码从一个名为timezone_info的表单字段中创建一个时区信息字典,该字段存储在表单中,由登录模板中包含的login.js文件中的一个小 JavaScript 函数填充:

(function() {
    let timezone_info = document.getElementById(‘timezone_info’);
 timezone_info.value = JSON.stringify(Intl.DateTimeFormat().
 ➥resolvedOptions());
}())

当浏览器渲染模板时,自我评估函数运行。它找到 HTML 页面中的timezone_info隐藏字段元素,并用调用函数Intl.DateTimeFormat().resolvedOptions()JSON.stringify结果填充它。在我的计算机上的 Chrome 浏览器中,这生成以下 JavaScript 对象:

{
    calendar: "gregory",
    day: "numeric",
    locale: "en-US",
    month: "numeric",
    numberingSystem: "latn",
    timeZone: "America/New_York",
    year: "numeric"
}

在呈现本地上下文中的时间和日期信息时,前面的对象是有用的信息。由于功能在用户的计算机上运行,这可能在世界上的任何地方,因此有必要在 JavaScript 中执行这项工作。这使得返回的数据与用户相关,而不是 MyBlog 应用程序可能运行的服务器。format_datetime过滤器函数使用timeZone字段来确定如何创建createdupdated时间戳的本地时区值。

提示:当你在基于 Web 的应用程序上工作时,在向用户展示数据时考虑时区是值得的。如果你将应用程序部署到互联网上,你的用户可能在世界上的任何地方。展示协调世界时(UTC)很容易,但对你的用户基础来说并不很有帮助。

11.6.3 更新处理程序

如果用户决定修改他们的博客内容并点击更新按钮,他们将被导向带有查询字符串"action=update"的调度函数。调度函数处理请求并调用blog_post_update()函数。该函数按照以下步骤填充并展示页面:

  1. blog_post_update@login_required装饰,要求用户登录才能更新帖子。

  2. 开始数据库上下文管理器。

  3. 创建一个基于传递的post_uid获取帖子的查询。

  4. 根据用户的角色修改查询,以获取仅活跃帖子或所有帖子。

  5. 执行查询并获取结果。

  6. 如果没有找到帖子,则函数使用NOT FOUND终止请求。

  7. 获取模板表单信息,并用提交的值填充字段。

  8. 是否点击了取消按钮?如果是,则函数将用户重定向到主页。

  9. 表单是否已提交,并且是否有效?如果是,则使用表单数据更新查询返回的帖子,将更新后的帖子保存到数据库,并将用户重定向到帖子显示页面以显示更新。

  10. 使用表单和查询返回的帖子渲染 post_update 模板。

11.6.4 更新表单

更新帖子的数据来自 content/forms.py 模块中的 PostUpdateForm。该表单包含在 post_update.xhtml 模板中使用的字段信息,用于构建和渲染 HTML 页面到浏览器。

该表单提供了在表单渲染时可见的 titlecontentpost_updatecancel 字段。它还提供了一个名为 active_state 的隐藏字段,其中包含用户选择的激活状态。激活状态由模板条件性地渲染两个其他字段——activatedeactivate——来控制。当前用户必须是内容的作者或 MyBlog 管理员,activatedeactivate 字段才能显示。

根据帖子的当前活动状态,其中一个或另一个字段以按钮的形式呈现给用户。如果用户点击按钮,则提交表单,并切换活动状态值。activatedeactivate 按钮代表互斥的操作,一次只显示页面上的一个。

11.6.5 更新模板

post_update.xhtml 模板负责将页面渲染到浏览器,登录用户可以在此修改帖子的内容。该模板使用传递给它的表单信息和数据来在浏览器中渲染更新帖子显示。根据当前用户的角色,他们可以使用条件呈现的激活和停用按钮切换帖子的活动状态。控制此行为的模板片段如下所示:

{% if can_set_blog_post_active_state(post) %}
    {% if post.active == True %}
     {{form.deactivate(class_="btn btn-danger me-2")}}
 {% else %}
     {{form.activate(class_="btn btn-success me-2")}}
 {% endif %}
{% endif %}

外部 if 语句调用 can_set_blog_post_active_state() 函数,该函数根据当前登录用户是否可以更改帖子的活动状态返回 TrueFalse。用户必须是管理员或帖子的作者才能这样做。

内部 if/else 条件语句确定要渲染哪个字段——deactivateactivate——取决于帖子的当前活动状态。

can_set_blog_post_active_state() 函数存在于 content.py 处理模块中,作为在模板处理上下文中提供的两个函数的一部分:

@ content_bp.context_processor                       ①
def utility_processor():
    def can_update_blog_post(post):                  ②
        if not current_user.is_anonymous:
            if current_user.role.permissions & 
            ➥(Role.Permissions.ADMINISTRATOR | 
            ➥Role.Permissions.EDITOR):
                return True
            if current_user.user_uid == post.user.user_uid:
                return True
        return False

    def can_set_blog_post_active_state(post):        ③
        if current_user.is_anonymous:
            return False
        if current_user.role.permissions & (Role.Permissions.ADMINISTRATOR):
            return True
        else:
            if current_user.user_uid == post.user.user_uid:
                return True
        return False

    return dict(                                     ④
        can_update_blog_post=can_update_blog_post,
        can_set_blog_post_active_state=can_set_blog_post_active_state,
    )

① 装饰器用于向内容蓝图添加模板上下文功能

② 如果当前登录用户是管理员或编辑,则返回 True,否则返回 False

③ 如果当前登录用户是管理员或帖子的作者,则返回 True

④ 返回要添加到模板上下文中的两个函数

11.7 内容评论层次结构

MyBlog 应用可以有多个内容帖子。每个帖子可以与多个评论相关联。这些一级评论中的每一个也可以与多个评论相关联。MyBlog 应用将限制评论嵌套到两级以保持合理。

图 11.5 中的层次结构显示了两个内容帖子,帖子 0 和 1。内容帖子 0 有两个与它相关联的 1 级评论,第一个评论有两个 2 级评论,第二个评论有一个 2 级评论。内容帖子 1 有三个 1 级评论,只有一个有 2 级评论。

图片

图 11.5 内容和评论的关系结构形成一个层次结构。

帖子内容可以包含 Markdown 语法,在显示渲染时使用,但通常以文本形式存储在数据库中。内容上的评论在显示时不会支持 Markdown,但也以文本形式存储。除了与内容相关的标题字段外,评论在数据库中存储时看起来非常像内容。

这表明了内容和评论之间的一种关系,我们可以在应用中利用这种关系。内容存储在post表中,根据列出的层次结构,将 1 级和 2 级评论存储在单独的表中并不无理。通过创建单独的表,post表可以与 1 级评论形成一对一的关系,而 1 级评论可以与 2 级评论形成一对一的关系。

三表数据库结构可以工作并提供所需的功能。然而,我认为它有一些缺点。首先,内容和评论帖子在结构上几乎相同。它们都与发布它们的用户相关的内容,并且有创建和更新时间戳。

它们之间的主要区别是评论没有标题。SQLite 数据库引擎不会为文本字段分配数据库空间,除非需要,因此不使用标题字段不会在磁盘空间上产生任何成本。

第二个缺点是评论的任意两级嵌套限制。对于 MyBlog 来说,两级限制有助于使本章的示例保持合理。在一个公开可用的应用中,要求可能会轻易地改变为三级、四级或更多级的评论。通过使用每个级别一个表来扩展对更多级别的支持意味着添加新表,其中每个新表本质上复制其父表。

我们可以通过巧妙的方法克服这些缺点。例如,与其将内容—1 级和 2 级评论—存储在几乎相同的单独表中,为什么不扩展现有的Post表以支持内容和评论帖子呢?这可以通过向表中添加一个parent_uid字段来实现,如图 11.6 所示。这样,一行可以同时是另一行的父行和某些其他父行的子行。

图片

图 11.6 自引用的 Post 表创建了一个层次化的内容/评论结构。

我们通过将 parent_uid 添加到 Post 表并将其作为同一表的 post_uid 的外键,创建了一个自引用的层次结构。任何 parent_uid 等于 NULL 的行是层次结构的根,也是一个内容帖子。任何非 NULL 的 parent_uid 引用表中的另一行,是一个子评论。子行引用的 parent_uid 可以是内容帖子或评论帖子。

提示:自引用表在尝试存储层次数据时非常有用。如果层次结构中的节点在结构上相同,或者足够接近相同,则这一点是正确的。

表中的每一行都可以与同一表内的子行列表建立一对一关系。没有父行的顶级行是内容帖子;其他的是评论。这种结构对表可以支持的评论嵌套深度没有固有的限制。使用这种自引用结构,两级的嵌套约束是应用程序的功能,而不是数据库的功能。

11.7.1 修改帖子类

要实现 Post 表的 ERD 图,必须在 models.py 模块中的 Post 类中进行更新。您将在 examples/CH_11/examples/02/app/models.py 中看到更改:

class Post(db.Model):
    __tablename__ = "post"
    post_uid = db.Column(db.String, primary_key=True, default=get_uuid)
    parent_uid = db.Column(db.String, 
    ➥db.ForeignKey("post.post_uid"), 
    ➥default=None)                               ①
    sort_key = db.Column(db.Integer, 
    ➥nullable=False, unique=True, 
    ➥default=get_next_sort_key)                  ②
    user_uid = db.Column(db.String, 
    ➥db.ForeignKey("user.user_uid"), 
    ➥nullable=False, index=True)
    title = db.Column(db.String)
    content = db.Column(db.String)
    children = db.relationship("Post", 
    ➥backref=db.backref("parent", 
    ➥remote_side=[post_uid], lazy="joined"))     ③
    active = db.Column(db.Boolean, nullable=False, default=True)
    created = db.Column(db.DateTime, 
    ➥nullable=False, 
    ➥default=datetime.now(tz=timezone.utc))
    updated = db.Column(db.DateTime, nullable=False, default=datetime.now(
        tz=timezone.utc), onupdate=datetime.now(tz=timezone.utc))

① 将 parent_uid 外键添加到帖子表的 post_uid

② 添加 sort_key,它是一个非主键的自增值

③ 添加子关系,创建与该帖子关联的子列表,并为每个子添加 "parent",引用其父

parent_uid 值在 post 表的行之间创建了一对多关系。children 属性在数据库中不存在,但由 SQLAlchemy 在查询返回 Post 对象时创建,添加与帖子关联的子列表。它还向子行添加了一个 parent 属性,引用其父行。

sort_key 属性用于在显示内容帖子及其相关评论时保持适当的嵌套顺序。sort_key 的默认值是一个在创建新行时调用的自定义 Python 函数:

def get_next_sort_key() -> int:
    with db_session_manager(session_close=False) as db_session:
        retval = db_session.query(func.ifnull(
        ➥func.max(Post.sort_key) + 1, 0)).scalar()
        if retval is None:
            raise RuntimeError("Failed to get new value for sort_key")
        return retval

get_next_sort_key() 函数从 post 表中获取当前的 sort_key 最大值,将其加 1,并返回该值。将函数作为 sort_key 的默认值创建了一个自动递增的唯一 sort_key 值,用于在 post 表中创建的每一行。这种行为模拟了数据库对主键字段的自增行为。不幸的是,SQLite 不允许非主键字段有这种行为,而 sort_key 就不是。该值在查询表时使用,以渲染帖子及其评论层次结构,这在另一个部分中展示。

11.7.2 显示处理程序

评论帖子是 MyBlog 应用程序中内容帖子的变体。由于这个原因,显示、创建和更新它们是通过修改 content.py 中的现有处理程序来处理的。还添加了额外的表单以获取用户输入以创建评论。这些更改位于 examples/CH_11/examples/02/app 目录中。

content.py 模块最显著的变化发生在 blog_post_display() 函数中。显示 MyBlog 内容帖子需要以有意义的层次顺序渲染与帖子关联的任何评论。以巧妙的方式构建 post 表并使其自引用意味着你必须对查询进行巧妙处理,以获取帖子内容和其评论。

图 11.5 显示了一个类似树的结构,其中一个根节点——内容帖子——分支到多个评论帖子节点。这种结构可以使用递归遍历。

由于内容和评论帖子在定义上是相同的,每个都可以有零个或多个子节点;相同的函数可以应用于每个。在节点上迭代关联的子节点列表,并进入每个子节点以再次使用该功能。

同样的功能会再次应用,直到达到没有子节点的节点,此时功能会上升至该子节点的父节点,并处理下一个子节点。这个过程会一直持续,直到整个附加到内容根节点的整个树都被遍历。

要使用 SQLAlchemy 创建此类功能,并在 SQL 中实现,我们将使用带有递归的公用表表达式(CTE)。CTE 是一个 SQL 查询的临时、命名的结果,用于更大、封装的查询的上下文中。一个递归 CTE 可以遍历像自引用的 Post 表这样的树结构。

blog_post_display(post_uid) 处理函数已被简化为:

def blog_post_display(post_uid):
    logger.debug("rendering blog post page")
    form = PostCommentForm()
    with db_session_manager() as db_session:
        posts = _build_posts_hierarchy(db_session, post_uid)
        if posts is None:
            flash(f"Unknown post uid: {post_uid}")
            abort(HTTPStatus.NOT_FOUND)
        return render_template("post.xhtml", form=form, posts=posts)

此处理函数从调用处理器的 URL 中接收 post_uid 值作为参数,并执行以下步骤:

  1. 获取与显示关联的表单,用于用户评论输入。

  2. 开始数据库会话上下文管理器。

  3. 获取与 post_uid 值相关的帖子层次结构。

  4. 我们是否得到了任何返回的 posts?如果没有,它将终止请求。

  5. 渲染 post.xhtml 模板,传递表单和帖子数据。

通过调用函数 _build_posts_hierarchy(db_session, post_uid) 获取帖子的层次结构。此函数将递归查询的相对复杂性从显示函数中移出,以提高清晰度。前导下划线 _ 字符仅是一个约定,表示该函数被认为是非公开的。_build_posts_hierarchy() 函数负责从数据库中获取帖子的层次结构,从根节点(parent_uid 等于 NULL)开始,并递归遍历树以获取所有评论:

def _build_posts_hierarchy(db_session, post_uid):
    # build the list of filters here to use in the CTE
    filters = [                                         ①
        Post.post_uid == post_uid,                      ①
        Post.parent_uid == None                         ①
    ]                                                   ①
    if current_user.is_anonymous or                     ②
    ➥current_user.can_view_posts():                    ②
        filters.append(Post.active == True)             ②

    # build the recursive CTE query
    hierarchy = (                                       ③
        db_session                                      ③
        .query(Post, Post.sort_key.label(               ③
        ➥"sorting_key"))                               ③
        .filter(*filters)                               ③
        .cte(name=‘hierarchy’, recursive=True)          ③
    )                                                   ③
    children = aliased(Post, name="c")                  ④
    hierarchy = hierarchy.union_all(                    ⑤
        db_session                                      ⑤
        .query(                                         ⑤
            children,                                   ⑤
            (                                           ⑤
                func.cast(hierarchy.c.sorting_key,      ⑤
                ➥String) +                             ⑤
                " " +                                   ⑤
                func.cast(children.sort_key,            ⑤
                ➥String)                               ⑤
            ).label("sorting_key")                      ⑤
        )    #                                          ⑤
        .filter(children.parent_uid ==                  ⑤
        ➥hierarchy.c.post_uid)                         ⑤
    )    
    # query the hierarchy for the post 
    ➥and it’s comments
    return (                                            ⑥
        db_session                                      ⑥
        .query(Post, func.cast(                         ⑥
        ➥hierarchy.c.sorting_key, String))             ⑥
        .select_entity_from(hierarchy)                  ⑥
        .order_by(hierarchy.c.sorting_key)              ⑥
        .all()                                          ⑥
    )                                                   ⑥

① 创建用于获取匹配传递的 post_uid 的帖子并确保它是一个根(内容)节点的过滤器

② 添加一个过滤器,以便查询只返回用户允许查看的帖子

③ 开始创建递归 CTE

④ 为 Post 类创建一个别名

⑤ 完成 CTE

⑥ 查询 CTE 以获取层次化帖子

这是 MyBlog 应用程序中最复杂的查询,值得解释。hierarchy 变量根据 post_uid 值设置为帖子的根节点。它也被声明为一个递归 CTE。记住,递归行为通过在类似对象上执行类似操作来遍历树。这种递归行为是通过将 union_all() 操作应用于 hierarchy 查询实例来提供的。它通过连接 parent_uidpost_uid 值来遍历树。

注意 sort_key 值的情况。sort_key 值是数据库中的一个自增整数,但查询将其转换为标记为 sorting_key 的字符串。通过比较两个帖子的 sort_key 值,具有更高 sort_key 值的帖子是在数据库中插入具有较低值的帖子之后插入的。这是因为 sort_key 值是自增的,所以新插入数据库的帖子会得到比任何其他先前帖子更大的 sort_key 值。

sorting_key 值是一个由 sort_key 值累积而成的字符串,从父级到子级再到子级,由空格字符分隔。sorting_key 值提供了查询返回的每个帖子的完整路径,按降序时间顺序排列。因为它是一个字符串而不是一个数字,所以它能够适当地排序以按顺序显示帖子内容和其评论。该函数返回查询递归 CTE 的结果,并使用 sorting_key 应用一个 order_by() 子句。

11.7.3 显示模板

post.xhtml 模板被修改以显示帖子内容和其评论。它有两个阶段:第一阶段与之前几乎相同地渲染内容;第二阶段添加了相关评论的层次结构,并缩进以表示层次。

由于内容帖子可以有多个评论,模板会遍历评论帖子以将它们渲染到 HTML 页面。对于每个评论,渲染操作几乎相同,因此功能在一个在评论循环的每次迭代期间调用的宏函数中。每个评论的缩进级别是通过在空格字符上拆分帖子的 sorting_key 值并使用返回数组的长度来计算缩进级别确定的。

模板还提供了创建新评论和编辑现有评论的界面元素。这是通过使用 Bootstrap 模态功能在当前显示上打开一个对话框窗口来创建和编辑评论来完成的。一个宏函数提供了必要的模态 HTML。

评论和模态宏都位于一个新文件中。遵循 Blueprint 命名空间,模板导入了一个特定于内容的多文件,content_macros .jinja。此文件位于content/templates文件夹中。在examples/CH_11/examples/02中运行 MyBlog 应用程序并导航到一个包含内容和相关评论的帖子,将渲染一个显示两者的页面。图 11.7 是渲染显示的截图。

图片

图 11.7 内容显示还将显示任何相关的评论和子评论。

11.8 创建评论

之前的显示展示了用于在内容上创建评论和在现有评论上评论的用户界面元素。这两种行为都依赖于创建一个引用父帖的帖子。

11.8.1 创建模板

我们正在改变演示的顺序,首先讨论评论创建模板,因为它已经存在于post.xhtml模板中。而不是离开当前显示的帖子去创建评论,MyBlog 使用 Bootstrap 创建模态窗口的能力。

模态窗口从属于主窗口,但禁用交互并以子窗口的形式显示在主窗口之上。这在 UI 设计中很有用,可以帮助保持用户的参考框架与当前任务相关联。

Bootstrap 在模态窗口将要出现的窗口的 HTML 中创建模态窗口的 HTML 元素。当父窗口渲染时,模态窗口的包含 HTML DOM元素被设置为不可见,它因为用户的操作而出现。

post.xhtml模板的末尾,调用了一个内容宏:

{{ content_macros.form_create_comment_modal() }}

文件examples/CH_11/examples/02/app/content/template/content-macros.jinja包含宏代码。该宏将构建模态窗口所需的 HTML 元素插入到渲染的帖子显示中。

显示中的每个评论按钮都会激活模态窗口,使其可见(图 11.8)。模态窗口展示一个表单,包含一个 HTML 文本区域用于输入评论。它还包含一个隐藏字段,其中填充了与该评论相关的父帖的post_uid值。隐藏字段是在点击评论按钮时被填充的。创建按钮提交表单以进行处理,取消按钮关闭模态窗口。因为模态窗口存在于post.xhtml模板中,所以当模板渲染时,父表单元素都是可用的。

图片

图 11.8 创建评论发生在当前内容显示上的模态窗口中。

11.8.2 创建表单

处理创建评论的表单存在于app/content/forms.py模块中。这个简单的表单创建了父帖的parent_post_uid隐藏字段,评论的文本区域字段,以及创建提交按钮。

11.8.3 创建处理程序

当用户输入了评论文本并点击创建按钮后,表单以 HTTP POST请求的形式提交给处理器,请求的 URL 是"/blog_post_create_comment":

@ content_bp.post("/blog_post_create_comment")
def blog_post_create_comment():
form = PostCommentForm()
if form.validate_on_submit():
    with db_session_manager() as db_session:
        post = Post(
            user_uid=current_user.user_uid,
            parent_uid=form.parent_post_uid.data,
            content=form.comment.data.strip(),
        )
        db_session.add(post)
        db_session.commit()
        root_post = post.parent
        while root_post.parent is not None:
            root_post = root_post.parent
        flash("Comment created")
        return redirect(url_for("content_bp.blog_post", post_uid=root_post.post_uid))
else:
    flash("No comment to create")
return redirect(url_for("intro_bp.home"))

处理器负责验证提交的表单并在数据库中创建新的评论帖子。创建的帖子有一个parent_uid值,这是通过将其作为该帖子的子级来生成的。

在将评论帖子提交到数据库后,while循环用于迭代层次结构并获取根帖子。根帖子用于将用户重定向到层次结构中根帖子,新创建的评论将在那里渲染和显示。

11.9 通知用户

我们还希望添加到 MyBlog 应用程序的一个新功能是,当某人他们关注的用户创建新的内容帖子时通知用户。在帖子上发表评论的用户会自动成为该帖子的关注者。

实现关注者创建用户和帖子之间的多对多关系。一个用户可以关注多个帖子,多个用户可以关注单个帖子。正如第十章所示,多对多关系使用关联表来连接两个其他表。examples/CH_11/examples/03/app/models.py模块被修改以添加关联表:

user_post = db.Table(
"user_post",
db.Column("user_uid", db.String, db.ForeignKey("user.user_uid")),
db.Column("post_uid", db.String, db.ForeignKey("post.post_uid"))
)

而不是创建一个用于模型化user_post表的类,它被创建为 SQLAlchemy 的Table类的实例。user_post表只有两个字段——指向相关表主键的外键。用户模型类也被修改,以添加它和Post模型之间的多对多关系连接:

posts_followed = db.relationship(
    "Post", 
    secondary=user_post, 
    backref=db.backref(
        "users_following", 
        lazy="dynamic"
    )
)

user.posts_followed属性不在数据库中,而是由 SQLAlchemy 维护。从查询返回的User类实例将有一个posts_followed属性,该属性是一个Post实例列表。

secondary参数通过user_post关联表将一个User实例与一个Post实例连接起来。backref参数在Post类中创建了一个users_following属性。这也不在数据库中,而是由 SQLAlchemy 维护。对于Post实例,users_following属性是一个跟随该PostUser实例列表。

要填充user_post关联表并创建多对多关系,blog_post_create_comment()处理器函数通过添加以下代码行进行了修改:

root_post = post.parent
while root_post.parent is not None:
root_post = root_post.parent
follow_root_post(db_session, root_post)
notify_root_post_followers(db_session, root_post)

在上一个示例中创建了一个向上遍历帖子层次结构的while循环,以获取root_post值。两个新函数follow_root_post()notify_root_post_followers()使用root_post值:

def follow_root_post(db_session, root_post):
user = (
    db_session.query(User)
    .filter(User.user_uid == current_user.user_uid)
    .one_or_none()
)
if user is not None and root_post not in 
➥user.posts_followed:
    user.posts_followed.append(root_post)

follow_root_post()函数获取当前用户的实例。当找到user时,如果该user尚未关注该帖子,则将root_post添加到posts_followed列表中:

def notify_root_post_followers(db_session, root_post):
post_url = url_for(
    "content_bp.blog_post",
    post_uid=root_post.post_uid,
    _external=True
)
for user_following in root_post.users_following:
    to = user_following.email
    subject = "A post you’re following has 
    ➥been updated"
    contents = (
        f"""Hi {user_following.first_name},
        A blog post you’re following has had a 
       ➥comment update added to it. You can view
        that post here: {post_url}
        Thank you!
        """
    )
    send_mail(to=to, subject=subject, 
    ➥contents=contents)

notify_root_post_followers()函数首先将post_url变量设置为新建内容帖子的 URL。然后它遍历关注帖子作者的用户的列表。在循环内部,它使用为认证创建的 emailer 模块发送包含post_url的简短电子邮件给user_following用户。

11.10 处理站点错误

到目前为止,MyBlog 应用程序已经尝试优雅地处理错误和异常,并将用户重定向到应用程序的另一个部分。在 try/except 块中抛出和捕获异常的情况下,异常处理包括记录错误或抛出另一个更具体的异常。

Flask 通过渲染非常通用的 HTML 来显示异常信息来处理冒泡的异常。这很好,因为异常得到了处理和报告,没有使 MyBlog 应用程序崩溃,但这并不是一个好的用户体验。更好的解决方案是在 MyBlog 应用程序的上下文中报告异常,同时保留导航栏并提供快速返回应用程序其他部分的方法。

Flask 提供了使用应用实例的register_error_handler方法注册错误处理函数的机制。查看examples/CH_11/examples/04/app/__init__.py中的create_app()函数,你会看到这些代码行:

app.register_error_handler(404, error_page)
app.register_error_handler(500, error_page)

这些代码行使用create_app()函数生成的 Flask 应用实例来调用register_error_handler()方法。调用时的第一个参数是已注册错误处理函数的 HTTP 错误代码,第二个参数是处理函数的名称。

该方法被调用两次,一次是为了404(页面未找到)错误,另一次是为了500(内部服务器错误)错误。可以通过这种方式处理任意数量的其他标准 HTTP 错误。两次调用都注册了相同的error_page()函数作为处理程序。error_page()函数位于__init__.py模块的底部:

def error_page(e):
    return render_template("error.xhtml", e=e), e.code

这个函数将导致错误的异常作为参数传递。在函数内部,渲染了一个新的模板"error.xhtml",并将异常值传递给它。异常代码值用作页面的 HTTP 返回值。examples/CH_11/examples/04/app/templates/error.xhtml模板文件执行了一些简单操作:

{% extends "base.xhtml" %}

{% block title %}{{ e.name }}{% endblock %}

{% block content %}
<div class="error_page mx-auto mt-3" style="width: 50%;">
  <div class="container">
      <div class="card text-center">
          <h5 class="card-header">
              {{ e.code }} : {{ e.name }}
          </h5>
          <div class="card-body">
              <div class="card-text">
                  {{ e.description }}
              </div>
          </div>
      </div>
  </div>
</div>
{% endblock %}

在用户体验方面,最重要的是模板继承自"base.xhtml"。这赋予了页面与 MyBlog 应用程序其余部分及其导航栏相同的样式。此外,这为发现自己处于错误页面的用户提供了一种返回应用程序其他页面的方法。模板的其余部分将异常代码、名称和描述的输出样式化为 Bootstrap 卡片。

跨站脚本

代码在 examples/CH_11/examples/04 中尝试处理的另一个关注领域是跨站脚本(XSS)注入攻击。这种攻击发生在 JavaScript 被注入到网站中,并在稍后运行在其他用户的浏览器上时。

由于创建内容和评论允许用户输入纯文本,因此该文本可能包含以下形式的嵌入 JavaScript 代码:

"... some benign content 
➥<script>malicious_function()</script> 
➥more plain content..."

文本随后被保存在数据库中。如果另一个用户查看包含此 JavaScript 的帖子,他们的浏览器无法知道该脚本可能存在危险并执行它。

为了防止这种行为,使用了一个名为 Bleach 的新模块来清洗用户输入的文本。Bleach 模块是本章 requirements.txt 文件的一部分,并在 content/forms.py 模块的顶部导入。在保存之前,用户输入的文本会通过 content/forms.py 模块进行过滤:

def remove_html_and_script_tags(
➥input_string: str) -> str:
    return bleach.clean(input_string) 
➥if input_string is not None else input_string

此函数如果 input_string 参数不是 None,则使用 bleach.clean 方法清洗 input_string 参数,否则它只返回 input_stringremove_html_and_script_tags() 函数被添加到包含 StringFieldPageDownField 元素的表单类中。例如,PostFormcontent 字段已更新为如下所示:

content = PageDownField(
    "Content",
    validators=[DataRequired()],
    filters=(remove_html_and_script_tags,),
    render_kw={"placeholder": " ", "tabindex": 2}
)

filters 参数传递了一个元组,第一个元素是 remove_html_and_script_tags 函数。当表单提交到服务器时,在表单提供数据之前,过滤器函数将在 blog_post_create() 中的此类调用之前执行:

content=form.content.data.strip()

以这种方式,在内容保存到数据库之前,任何嵌入的 HTML 代码/脚本都被禁用。

11.11 结束语

这是一段很长的内容,即使它使用了您在其他应用程序部分中看到的一些模式,但它代表了在您的技能集中取得的一个重大里程碑。通过开发 MyBlog 应用程序,您现在能够从宏观上看到构建更大应用程序所需的整体图景。您也能够从微观上看到实现大型应用程序各个部分的详细视图。

MyBlog 应用程序现在已经完成,因为它已经达到了本书所设定的目标。作为一个教学工具,我希望它已经很好地为您服务。该应用程序提供了许多添加、修改和改进的机会。我也认为,如果您着手开发另一个 Web 应用程序,它是一个很好的参考。为了适应一个老生常谈的说法,“这留作开发者的练习。”

摘要

  • 使用 SQLAlchemy 的 Python 类生成 MyBlog API 以创建帖子以及用户与这些帖子之间的关系。这样做可以模拟数据库表,使它们可用于您的 Python 应用程序。

  • 通过利用 Flask-PageDown 模块,我们向 MyBlog 应用程序添加了有用的功能,而无需自己编写这些功能。这是进阶开发者的一项关键特性,即能够识别他人的才能并将其融入我们的工作中。

  • 在单个数据库表中维护的自引用分层数据是一个强大的概念,并且是一个值得利用的特性。通过使用公共表表达式(CTEs)和递归,使用 SQLAlchemy 和 Python 实现这一点是可能的。

  • Bootstrap 具有创建模态对话框的有用功能。这些功能用于生成表单以收集数据,并保持它们在其当前工作流程的上下文中。使用模态对话框来对用户内容帖子进行评论,就是利用了这一特性。

12 我们到了吗?

本章涵盖

  • 测试

  • 调试

  • 工具

  • 网络通信

  • 协作

我们到了吗?

所以,请相信我,我会把这个书翻过来!

开个玩笑,阅读这本书为 Python 开发者提供了一个巨大的飞跃。如果您已经完成了示例并构建了 MyBlog 代码,您已经创建了一个有趣的应用程序并管理了实现它的复杂性。您已经从许多软件工程领域汇集了工具和技术,以创建一个统一的整体,提供有用的功能。

更重要的是,您已经遵循了良好的实践来管理应用程序的复杂性。管理这种复杂性意味着 MyBlog 应用程序随着时间的推移是可维护的,并且可以在不使结构脆弱的情况下进行扩展。

要回答“我们到了吗?”这个问题,会引发古老的回答,“嗯,是的,也不是。”让我们谈谈为什么这个答案不是确定的,以及这如何成为一次充满活力和令人兴奋的邀请,去进行一次冒险之旅,进一步扩展你作为开发者的技能。

12.1 测试

创建软件应用程序的一个重要方面是测试它们。我故意没有包括任何关于测试本书中展示的代码的讨论或示例。我这样做有几个原因。

首先,编写测试代码往往会产生与被测试的应用程序一样多,如果不是更多,的代码。这并不是避免它的理由,但在本书的上下文中,它会在示例之上增加另一个技术工作领域。这也会分散那些示例的教育意图。

其次,软件测试是一个很大的主题,值得有它自己的书。包括软件测试含义的一部分会对主题和您都造成不利。关于 Python 世界测试的伟大书籍是 Brian Okken 的《Python Testing with pytest》(mng.bz/Zql9)。

软件测试从自动化中受益于各个方面。使用测试工具和框架(如 pytest)来自动化测试提供了一致性,并在开发过程中条件发生变化时提供早期警告。

此外,在大多数情况下,如果测试不是由应用程序的开发者执行,那就更好了。作为开发者,无意识地遵循“快乐路径”以产生期望的结果是非常容易的。这与您的软件用户所做的事情非常不同。他们会通过边缘情况和未预见的边界条件来推动您的软件达到极限。话虽如此,有许多类型的测试需要考虑,这些测试适用于您开发和开发的应用程序。

12.1.1 单元测试

单元测试是应用程序开发者创建测试的情况之一。单元测试隔离一个函数或组件,并验证它是否处理传递给函数的输入并产生预期的输出。除了测试预期的输入产生预期的输出外,测试还应练习边缘情况。测试应确定函数是否合理地处理意外的输入,以及输出是否是预期的错误条件或异常。

单元测试应仅检查正在测试的函数或组件,而不是测试框架无法控制或预测的外部资源的依赖。访问数据库、网络或某些不可预测的时间操作可能导致测试失败,因为资源失败。在这些情况下,外部资源必须被“模拟”以使测试可重复。模拟外部资源是用模拟其行为但以可重复、可靠的方式替换实际资源对象。

12.1.2 功能测试

功能测试建立在单元测试的基础上,通过检查系统和子系统的功能,这些系统和子系统建立在函数和组件之上。测试的目的是将系统的实际功能与该系统的需求进行比较。这依赖于规格说明来指导系统的开发和其预期目的。

12.1.3 端到端测试

端到端(e2e)测试确定应用程序提供的流程是否从开始到结束都按预期行为。用户是否能够开始、继续并完成应用程序旨在提供的流程?

12.1.4 集成测试

集成测试类似于端到端测试,但增加了系统在目标硬件和应用程序将部署的环境中运行。这并不总是可能的,但应采取步骤尽可能接近目标硬件和环境。

12.1.5 压力测试

压力测试确定在目标硬件上运行并处于预期环境中的应用程序是否能够处理其设计的负载。单元测试通常使用受控数据的小子集来测试功能。负载测试数据集可以大得多,以模拟实际用例的数据处理预期。对于多用户系统,如 Web 应用程序,负载测试还检查系统是否能够处理预期同时访问系统的用户数量,并且足够响应以满足他们的需求。

12.1.6 性能测试

性能测试确定系统是否满足性能要求。这些要求可以用处理速度、处理指定数量的多个请求、数据处理吞吐量和其他指标来表示。这种测试依赖于对性能指标有清晰的理解,如何进行测量,以及用户和开发者都理解和同意这些指标。

12.1.7 回归测试

回归测试帮助开发者发现系统中的代码修改是否破坏了功能、对资源消耗产生不利影响或改变了性能特征。回归测试可以自动化检查和报告端到端测试的结果。

12.1.8 可访问性测试

可访问性测试非常依赖于你开发的软件的受众是谁。如果你正在为其他开发者创建库代码,可访问性问题可能集中在开发者体验上。

然而,如果你正在创建将向任何人普遍提供的移动或 Web 应用程序,你需要考虑残疾用户如何访问你的应用程序。相关的残疾可能包括视力或听力障碍以及其他身体和认知问题。

12.1.9 接受测试

接受测试关注的是软件或应用程序是否满足启动其创建的要求。这些要求可以由你自己、你的同事、你的公司或你的客户定义。这些是决定应用程序是否满足既定要求并可视为完整的项目利益相关者。

假设一个完整且清晰的规范文档是接受测试的必要条件是很诱人的。根据我的经验,对于平均的软件项目,通常不存在这样的文档。需求往往是模糊和开放式的,这可能导致用户和开发者对应用程序功能的不同假设和理解。这种误解可能导致最终用户和开发者之间的关系变得对抗性,尤其是在开发者接受需求,而用户直到项目结束时才参与接受测试的情况下。

采取不同的方法通常可以创造一条更好的接受路径,并得到各方面的同意。因为需求往往定义不足,所以开发者最好在迭代过程中让用户参与。随着功能的开发,它们被展示给用户,并且当产品及其需求被双方更好地理解时,会针对课程修正进行解决。

迭代开发和接受测试实践可以将对抗性关系转变为更协作的关系。最终的接受测试更有可能成功,因为项目成果是由用户和开发者共同参与的。

12.2 调试

如果你尝试过任何示例、修改过它们或编写了自己的程序,你已经在代码中遇到了错误。没有完美的程序,错误是开发者生活的一部分。存在运行时错误,如尝试除以零,以及逻辑错误,即程序的结果不是你想要的或预期的。作为开发者成长包括能够找到和修复程序代码中的问题——无论是自己的还是他人的。

12.2.1 重现错误

在深入阅读数千行,甚至数万行代码之前,确定错误是否可以重现是至关重要的。是否存在一系列可以采取的步骤来可靠地引发错误?是否存在一组可以提供给程序的数据来引发错误?

如果无法一致地执行错误,找到和修复错误就变得非常困难。这可能意味着需要花费时间编写封装代码或单元测试来隔离问题并告知问题何时得到解决。

12.2.2 断点

将调试器设置为断点作为你的工具集的一部分,对于在应用程序中查找错误非常有效。断点是在你的应用程序中设置的一个位置,当应用程序运行到该位置时,会停止运行并将控制权传递给调试器。

当调试器拦截断点时,你可以检查运行中的应用程序在该时刻的状态。你可以查看变量、评估语句,并单步执行到下一行代码以查看结果。

许多调试器可以设置条件断点,这些断点仅在特定条件为真时才会触发。例如,你可以重现一个错误,但只有在经过数千次通过大量数据集的迭代之后。可以设置一个条件断点,在计数器等于触发错误所需的迭代次数时触发。在断点处检查代码是确定在那个时间点应用程序中发生什么的有价值工具。

12.2.3 记录日志

能够在断点处观察应用程序的状态是有价值的,但有时你还需要看到事件的历史。在应用程序运行期间记录事件,为你提供了应用程序通过代码所走过的路径的视图。

将时间戳添加到这些日志事件中,也为你提供了这些事件的编年史,包括它们发生的时间以及它们之间经过的时间。你可以添加打印语句来做到这一点,但 Python 提供了一个更好的工具——日志系统。Python 的日志模块为你的应用程序的内部工作提供了大量的可见性。

如果你在代码中添加了 logger.debug(...) 语句,你可以记录所需的所有信息来帮助调试应用程序。然后,当应用程序部署后,可以将 logger.level 设置为 INFO,这样调试语句就会被忽略。这意味着 logger.debug(...) 语句可以保留在代码中,而与通常需要从应用程序的日志输出中移除的打印语句不同。如果出现另一个错误,可以将 logger.level 设置为 DEBUG,这样你的 logger.debug(...) 语句就会再次激活,以帮助找到和解决新的错误。

12.2.4 不良结果

错误是应用程序崩溃还是产生了不良结果?在这种情况下,使用调试器(独立或集成在 IDE 中)来了解错误发生或即将发生的应用程序状态非常有用。

寻找产生不良结果的计算可能意味着在调用栈(操作顺序和函数调用)中来回移动,以观察对结果有贡献的值。这里可以使用便签纸和铅笔,或者文本编辑器,来跟踪这些中间值。如果计算看起来是正确的,那么可能被应用程序提供的数据集包含不良数据。

12.2.5 排除法

通常,寻找错误是一个排除过程,不断缩小错误存在的领域,直到找到它。这个过程可能发生在代码或数据中。使用调试器断点或日志语句可以帮助缩小代码中的领域。

如果你怀疑输入数据集是问题的根源,使用分而治之的方法来缩小问题数据。将数据分成两半,一次只向应用程序提供一半。继续重复这个过程,直到找到触发不良结果的值。即使对于大型数据集,这个过程也需要相对较少的迭代。

12.2.6 橡皮鸭问题

可能最简单,有时也是最有成效的解决问题的途径是与朋友或同事讨论问题。将你的想法用语言表达出来,以澄清问题,这通常能提出解决方案或通往解决方案的途径。

12.3 工具

就像任何复杂而有趣的任务一样,都有可用的工具帮助你实现目标。了解有用的工具并熟练掌握它们可以使你成为一个更强大的开发者。

12.3.1 源代码控制

伴随本书的示例代码存储在 GitHub 上托管的 Git 仓库中。Git 是用于创建创建应用程序所需的源代码文件的仓库的工具之一。仓库工具有助于管理项目开发的历史和该历史的文档。

如果你是一个应用程序的唯一开发者,学习如何使用源代码管理工具仍然是非常有价值的。能够审查和恢复你工作的历史,在长时间开发复杂应用程序时可能非常有价值。如果你是团队中的一员,正在开发应用程序,源代码控制是必需的,以帮助管理和防止多人同时工作在相同代码部分时的冲突。

最后,使用像 GitHub 这样的托管解决方案提供了一种稳定且方便的备份解决方案,这超出了你本地维护的任何备份系统。你确实备份了你的硬盘,不是吗?

12.3.2 优化

当我开始编写软件时,我幻想着创建能够对玩家输入做出反应的游戏,其中包含动态图像。当时,计算机是 8 位系统,带有一些 16 位功能,内存非常有限,CPU 运行在单数兆赫兹范围内。

在那些目标下,在那些条件下,为了性能而优化代码是必要的。通过仅使用代码来找出如何从系统中提取更多速度也是一件很有趣的事情。

优化的诱惑就像是一个迷人的海妖之歌,吸引了许多开发者。软件在现代计算机上运行得特别快,思考如何通过不同的实现、数据结构、缓存以及其他众多技术来使应用程序运行更快是非常诱人的。

在优化应用程序时,首先需要考虑的是性能目标。像“让它更快”这样的泛泛之谈并不是一个明确的要求。任何合理复杂的应用程序都提供了许多功能;你必须确定哪些功能是重要的,以及需要提高多少。

需要考虑的第二个要素是衡量应用程序的性能。测量当前基线性能对于确定应用程序中的更改是否改善了性能非常重要。

在考虑优化应用程序时,记住 90/10 规则是有用的。作为一个经验法则,许多应用程序 90%的时间都花在 10%的代码上。如果你开始优化之旅,这条规则意味着你将 90%的开发时间用于工作在应用程序大部分时间所花费的 10%的代码上。

请记住我在第一章中讨论的一个相关概念——作为开发者优化你的时间。达到一个“足够快”的应用程序状态,即立即提供预期功能,通常比稍后交付的略微更快的应用程序更受欢迎。记住这句谚语,“交付本身就是一项功能。”

最后,通过在更快的计算机上运行,在带宽更宽的网络中运行,使用容量更大的数据库,以及其他应用程序之外的其他依赖项,可以极大地提高应用程序的性能。这类变化在开发者时间方面通常成本较低。

精通一个工具也意味着知道何时不要使用它。

12.3.3 容器

容器为应用程序提供一个由开发者配置的环境,以便应用程序在其中运行。这样,容器中的应用程序可以运行在任何可以托管容器的计算资源上。无论容器运行在哪个主机上,容器中的应用程序始终与相同的环境进行交互。

容器中的应用程序运行在通常称为“客户”操作系统的环境中。客户操作系统是完整操作系统的一个子集,并且比传统虚拟机(VM)小得多。容器中的应用程序会调用客户操作系统以获取服务,而客户操作系统反过来又会调用“主机”操作系统以获取其所在计算资源的服务。

应用也可以直接在运行在开发计算机上的容器中开发,该计算机作为容器的宿主。这种做法的优势在于,可以在与部署环境(容器)相同的环境中开发应用。

12.3.4 数据库

MyBlog 应用使用 SQLite 数据库来持久化应用用户创建的内容和关系。RDBMS 通常比我们在这里创建的更大、更复杂,包含更多的表,表中包含数百万行数据。

在过去,我参与的工作远远多于我创建的数据库。作为一名开发者,了解更多关于数据库结构、工具和技术对于扩展数据库功能和维护其性能是有用的。本书中介绍的数据库通过消除冗余数据并利用 RDBMS 中可能的关系来实现这一点。了解何时可以接受冗余数据以改善应用查询性能也同样重要。

一些数据库系统是基于文档而非基于表。这些通常被称为 NoSQL 数据库,因为它们有时不提供 SQL 访问数据。相反,通过调用数据库 API 并传递参数来访问数据。

NoSQL 文档型数据库通常以 JavaScript 对象表示法(JSON)结构存储信息。与具有严格数据类型的表结构不同,存储的 JSON 数据可以随意动态更改。

这两种数据库方法都有优点和缺点,最终取决于开发者和应用利益相关者来确定哪种最适合应用的需求。NoSQL 数据库在某些类型的应用中检索数据可能更快。关系型数据库管理系统(RDBMS)数据库可以提供更多结构和数据一致性。

在考虑这两种数据库类型时,还有一个额外的复杂性,那就是两者都在越来越多地获得对方的功能。现代 RDBMS 数据库提供了将 JSON 数据作为表中的列存储的能力,以及与 JSON 文档中包含的数据交互的功能。同样,NoSQL 数据库也提供了 SQL 接口,允许进行更传统的查询以访问管理的数据。

12.3.5 语言

这是一本 Python 书籍,Python 是一种功能强大的通用语言,是许多可以从计算机应用中受益的技术领域的正确选择。但这并不意味着 Python 是唯一,甚至是在每种情况下都是最佳选择。

本书的一个目标是在构建 Python 应用时为你提供一套丰富的工具箱。然而,有时手头的难题可能需要完全不同的工具箱。学习和了解其他编程语言将使有扎实基础的开发者在其职业生涯的许多方面受益。

例如,JavaScript 在网页浏览器中工作时基本上是标准语言。它的超集 TypeScript 也在浏览器和服务器端语言中获得了吸引力。

Rust、C#、Go、Java、Kotlin 以及许多其他语言都广泛应用于各种编程领域,并且得到了广泛的接受。这些领域有所重叠,关于选择哪种语言的决定可以基于哪种语言对软件开发者来说最吸引人和舒适。

在我的整个职业生涯中,我使用过 Fortran、Pascal、C/C++、Visual Basic、PHP、JavaScript、TypeScript,当然还有 Python。其中一些我会乐意再次使用,一些则宁愿不使用,但它们在当时的时刻都为我及我的职业生涯提供了很好的服务。保持最新是作为开发者有趣且具有挑战性的一个方面。花时间学习新技术工具应该对你和你的生活有益,所以请明智地利用时间。

12.4 操作系统环境

世界上运行的大多数应用程序都托管在 Windows、Linux 或 Mac 计算机上。作为一名开发者,你工作的计算机也最可能是 Windows、Linux 或 Mac 系统。你可以花费整个职业生涯在一个环境中开发并部署你的应用程序,但这样做可能会缩小你可以编写的应用程序类型的领域。

使用 Python 编程可以使你创建的应用程序在大多数情况下对运行在哪个操作系统上并不知情。正是那最后的部分,“大多数情况下”,值得我们牢记。

熟悉为其他操作系统开发是有价值的,因为它为你提供了另一个工具,以触及更广泛的受众。随着云计算的日益普及以及容器在应用程序中的应用,在一个平台上开发并在另一个平台上部署是常见的。

12.5 云计算

云计算系统允许计算资源位于其他地方,并通过互联网安全访问。除了将运行和维护计算硬件的责任转移到服务之外,它还使开发者能够根据用户需求适当地调整计算资源和能力。资源的规模和能力也可以根据系统的工作负载动态地上下调整。

应用程序很少独立执行,并且依赖于网络、文件存储、负载均衡器、数据库等。这些依赖关系也来自云服务提供商,并且可以扩展以满足整个集成系统的需求。应用程序可以在云提供商的计算实例中独立运行,但应用程序存在于容器中的情况越来越普遍。

12.6 网络

世界是一个非常紧密联系的地方,而且一直在变得更加紧密。联系以及它们为对话和我们的选择带来的东西以非凡的方式改变了社会的结构。一个独立工作的软件应用程序将是一个罕见的例外,因为几乎所有重要的应用程序都是在连接到网络的环境中运行并依赖的,而且经常延伸到互联网之外。

在开发 MyBlog 应用程序——让服务器运行并使用本地浏览器访问它时——很容易忘记它是一个网络应用程序。没有理由应用程序不能在一个世界的一部分的服务器上运行,同时被世界另一部分的网络浏览器访问。为连接到网络的计算机系统编写应用程序意味着你的应用程序可以为其他系统提供信息和资源,同时也可以消耗网络上其他系统提供的信息和资源。

所有现代语言都提供了工具和库来与网络接口并跨网络进行通信。MyBlog 应用程序是一个使用 HTTP 协议将服务器连接到浏览器的例子。学习如何使用和开发网络应用程序可以通过数量级增加你的应用程序的强大功能和特性集。

12.7 协作

建立关系是成为一个扎实开发者的重要方面。当然,在数据库中创建和维护关系是至关重要的,但与你的生活中的人建立和维护关系要重要得多。

即使是一个个人项目的唯一开发者,你也会在网站、博客和用户组上提问,向同事和朋友寻求帮助,寻求解决你将面临的挑战。记住这个成语“好的开发者是懒惰的”?这并不意味着扎实的基础开发者完成项目慢;这意味着我们利用已有的解决方案来更快地实现元素。

成为社区的一员是双向的。我们应该多听少说,努力提供比我们要求的更多帮助,并努力建立和维护关系,以便社区中的其他人来寻求我们。

成为多样化开发者社区的一员意味着会遇到各种观点。根据我的经验,这种接触提供了我独自无法建立的方法、思想和解决方案。

协作是一个乘数。连接和与人分享可以多倍地增加你作为开发者的才能和能力。

12.8 结束语

您作为一名扎实的 Python 开发者的旅程已经走了很长的路——从早期章节通过望远镜观察,到众多可能的终点和完全实现且专注的应用程序中的许多细节。作为软件开发者的特点与其他任何工程领域都不同。我们工作的领域从无法理解的巨大宏观视角到难以想象的微小细节。

我已经开发了将近四十年的软件,并接受我还有很多东西想要学习。学习的渴望和幸运的能力对我来说一直是关键。

我写这本书的目标是帮助您沿着一条道路前进,这条道路通向成为一名扎实的 Python 开发者。然而,对于“我们到了吗?”这个问题,答案是高兴地“还没有。”您正处于一个分叉路口,通往许多路径,您可以跟随其中任何一条。我希望您发现前方的旅程同样富有启发性、愉快,并且尽可能有趣。

摘要

  • 应用程序测试是开发有用且稳定的应用程序的重要方面。测试应用程序有许多方面——一些可以自动化,而一些则不能。这同样是一个值得单独成书的大话题。

  • 能够重现、查找和修复错误是扎实开发者的一项基本技能。良好的工具和正确的心态是开始调试应用程序的起点。

  • 代码优化是一把双刃剑。它可以为应用程序的性能带来巨大的改进,也可能是一个巨大的时间黑洞。有理由优化应用程序,有具体的目标要实现,以及有衡量你是否达到这些目标的指标,这些是至关重要的第一步。

  • 我们只是触及了数据库作为开发者的你以及作为资源的你的应用所能为你带来的功能。深入了解它们的性能和限制是值得花费时间的。

附录。你的开发环境

作为一名 Python 开发者,你需要在你的电脑上有一个合适的环境。开发者环境必须覆盖很多领域。一般来说,它应该包括一套你熟悉的工具和一些关于你将如何组织项目目录的想法。根据你的电脑,它可能还包括你设置的环境变量,以帮助使事情更加自动化,因此更有用。

你会发现你即将创建和配置的是一个构建你自己的有用且强大的开发环境的良好起点。对于那些已经有一个你感到舒适的开发环境的人来说,你可以自由地浏览这部分内容。然而,请注意这里提出的任何差异;你需要在你的环境中考虑到这些差异。

A.1 安装 Python

首先,你需要在你的电脑上安装 Python。这看起来可能很明显,但实际上可能比最初看起来要复杂一些。并非所有操作系统都预装了 Python,有时即使安装了 Python,其版本也可能比当前可用的版本旧。即使你电脑上安装的 Python 版本是较新的,仍然建议安装 Python 以供你在开发时使用。

如果 Python 与操作系统一起安装,那么有很大可能性操作系统正在使用 Python 进行系统级功能。如果是这种情况,那么也有很大可能性你的操作系统依赖于它所带的 Python 版本以及为该版本安装的任何模块来使用。

本书中的编程示例将要求你使用 Python 包管理工具 pip 安装额外的第三方库。在系统 Python 中安装这些额外的库不是一个好主意,因为新安装的库版本可能会改变现有系统需求的功能并导致其损坏。你也不想操作系统更新改变你依赖的 Python 功能,这些功能对你的开发工作至关重要。

考虑到这一点,你将安装 3.10.3 版本的 Python,这将与任何操作系统安装的版本区分开来。新安装的 Python 版本完全受你控制,且独立于操作系统。这意味着你可以根据需要添加、删除和更新库模块,并且只有你的程序代码会受到 影响。

你需要安装稳定的 3.10.3 版本的 Python,这样你将拥有与本书中示例程序编写的和测试的相同 Python 版本,以最小化运行时问题。

A.1.1 Windows

Python 可能默认安装在你的 Windows 操作系统上,也可能没有安装,因为 Windows 操作系统并不一定使用 Python。所以一旦 Python 被安装,它将仅用于你的开发工作。

近期 Windows 版本在 Microsoft Store 中提供 Python,这使得安装变得非常简单。本写作时,Microsoft Store 中的 Python 版本正在评估中,并不能保证所有功能都稳定。这并不意味着您不应该使用它,只是您应该意识到这些问题。

如果您愿意,可以通过在浏览器中导航到www.python.org并遵循下载链接来使用适用于您 Windows 版本的稳定 3.10.3 版本的 Python。对于大多数用户来说,适合您 CPU 和操作系统版本的执行安装程序是正确的版本。

在安装过程中,安装程序允许您勾选一个复选框,将 Python 添加到您的PATH环境变量中。勾选此复选框,以避免将来遇到麻烦。如果您错过了这一步,并且 Python 无法在命令提示符中的 PowerShell 中运行,您始终可以重新运行安装程序并将 Python 添加到路径中。

A.1.2 Mac

在 Mac 上,安装了一个较旧的 Python 版本,您应该避免在开发中使用它。相反,安装一个完全独立于系统的 3.10.3 版本的 Python。要执行此安装,请使用pyenv实用程序。此程序允许您安装您想要的任意多个 Python 版本并在它们之间切换。对于本书中的示例,请安装 Python 版本 3.10.3。

您需要在您的 Mac 上安装 Homebrew 程序才能执行下一步。Homebrew 是 Mac OS 的包管理器,您可以使用它来安装许多有用的命令行工具,如pyenv。Homebrew 程序及其安装说明可在以下网址找到:brew.sh。安装brew程序后,打开您的终端程序,按照以下命令行步骤安装pyenv

  1. 在您的 Mac 上安装pyenv实用程序,请运行以下命令:brew install pyenv.

  2. 要将有用的设置信息添加到您的终端配置文件中,以使使用pyenv更方便,请运行以下命令以将pyenv支持添加到您的配置文件(对我来说是.zshrc,但可能是.bash_profile):

    echo -e 'if command -v pyenv 1>/dev/null 2>&1; then\n  
    ➥eval "$(pyenv init -)"\nfi' >> ~/.zshrc
    
  3. 要重新运行您的 shell 初始化脚本并激活之前的命令,请运行以下命令:exec $SHELL``.

  4. 要在您的计算机的 home 文件夹中安装 Python v3.10.3,请运行以下命令:pyenv install 3.10.3.

  5. 要验证 Python 的安装和版本,请运行以下命令:pyenv versions.

注意:如果您有一台配备 M1 或更高 CPU 的较新 Mac,之前的步骤可能对您来说有所不同。

A.1.3 Linux

通常使用的 Linux 版本有很多,在此书中为所有这些版本提供pyenv安装说明会显得尴尬,并且超出了本书的范围。然而,如果您正在使用 Linux 作为您的开发平台,您可能已经熟悉了如何在您使用的 Linux 系统上找到安装应用程序(如pyenv)所需的内容。

即使在已经安装了 Python 3 的 Linux 系统中,最好还是安装并明确控制你将要用于开发的 Python 版本。一旦安装了pyenv,可以使用以下命令行来安装 Python 版本 3.10.3:

pyenv install 3.10.3
pyenv versions

第一个命令在由pyenv控制的目录中安装了本书示例中将要使用的 Python 版本。这个版本与使用pyenv安装的任何其他 Python 版本以及系统 Python 版本都保持独立。第二个命令将列出安装的版本,应该会显示你刚刚安装的版本。

A.2 Python 虚拟环境

现在你已经在你的计算机上完全独立于任何系统安装的 Python 安装了 Python 3.10.3,你可能认为你已经准备好了。即使使用pyenv工具安装了 Python 版本,你仍然希望在项目中使用另一个抽象级别来使用该版本。

Python 3.10.3 提供了创建虚拟环境的内置能力。Python 虚拟环境并不是你可能熟悉的虚拟机,如 VMWare、Parallels 或 VirtualBox。这些工具允许整个操作系统作为客户机在另一个操作系统中运行。Python 虚拟环境只在项目目录内创建一个 Python 安装。这个 Python 安装可以通过pip命令添加模块,并且这些模块只在该项目的 Python 中安装和可用。

你可以使用pyenv安装相同版本的 Python 的多个项目,但每个 Python 虚拟环境都是相互独立的。这意味着你可以在每个虚拟环境中安装不同的模块版本,而不会发生冲突。

注意:Python 虚拟环境的价值不容小觑。能够创建、维护和使用稳定且完整的 Python 和库安装,对于消除未来的问题大有裨益。毕竟,你想要成为一名 Python 开发者,而不是系统管理员。

随 Python 一起提供的pip命令行工具是一个出色的实用工具。它是 Python 的包安装器,允许你从 Python 包索引(pypi.org/)添加额外的模块。Python 包索引中可用的模块提供了超出 Python 标准库的功能。本书中的代码示例使用了该资源中的许多模块。

A.2.1 Windows

在 Windows 系统中,你直接安装 Python 而不是使用pyenv,因为 Windows 不将 Python 作为其操作的一部分。你仍然希望使用特定于项目的 Python 虚拟环境,以将系统级别的 Python 安装与你要使用pip安装的任何模块分开。

要运行 Python 虚拟环境的激活和关闭脚本,你需要更改你电脑的执行策略。为此,你需要以管理员身份运行 PowerShell 程序。按照以下步骤操作:

  1. 点击 Windows 开始图标。

  2. 滚动到 PowerShell 菜单选择并下拉子菜单。

  3. 右键单击 PowerShell 子菜单项。

  4. 从上下文菜单中选择以管理员身份运行。

  5. 一旦你以管理员身份运行 PowerShell,运行以下命令:

    Set ExecutionPolicy Unrestricted
    

系统会提示你一个问题,你用y回答并按回车键。此时,退出 PowerShell 以退出管理员模式。你只需要做一次,因为这是一个系统范围的设置。

再次打开 PowerShell 程序——不要以管理员身份,以获得命令行提示符并按照以下步骤创建一个针对项目目录的新 Python 虚拟环境:

  1. 运行命令mkdir <项目目录名称>

  2. 运行命令cd <项目目录名称>

  3. 运行命令python -m venv .venv

  4. 运行命令.venv/Scripts/activate

  5. 运行命令python -m pip install –-upgrade pip [可选]。

第 1 行创建一个新项目目录,你可以给它起任何你想要的名字。第 2 行将你的当前工作上下文切换到新创建的目录。第 3 行使用 Python 创建 Python 虚拟环境。这可能需要一些时间来完成。

第 4 行激活了虚拟环境,在命令提示符前加上(.venv),表示环境已激活。一旦环境激活,任何额外安装的库都将安装在.venv目录中,而不会影响你之前安装的 Python 版本。要关闭虚拟环境,只需在命令提示符中输入deactivate

第 5 行是可选的,用于升级你刚刚设置的 Python 虚拟环境中的pip命令版本。如果pip检测到你正在运行较旧版本,它将打印出一条消息,告诉你正在运行较旧版本,并建议你更新该版本。如果你愿意,可以忽略这条信息并跳过第 5 行。我包括它是因为我运行了该命令以停止看到那条消息。

A.2.2 Mac 和 Linux

在 Mac 上设置 Python 虚拟环境如果已经使用pyenv安装了 Python 3.10.3 版本,就像之前描述的那样,是非常直接的。打开你的终端程序,按照以下步骤创建一个针对项目目录的新 Python 虚拟环境:

  1. 运行命令mkdir <项目目录名称>

  2. 运行命令cd <项目目录名称>

  3. 运行命令pyenv local 3.10.3

  4. 运行命令python -m venv .venv

  5. 运行命令source .venv/bin/activate

  6. 运行命令pip install –-upgrade pip [可选]。

第 1 行创建一个名为你想要命名的新的项目目录。第 2 行将你的当前工作上下文更改为新创建的目录。第 3 行创建本地文件.python-versionpyenv使用它来控制当你在这个目录中工作时运行哪个版本的 Python——在这种情况下,是 3.10.3 版本。

第 4 行使用本地 Python 版本在.venv目录中创建 Python 虚拟环境。第 5 行激活虚拟环境,在命令提示符前加上(.venv),表示环境已激活。一旦环境激活,任何额外的库都将安装到.venv目录中,而不会影响之前安装的pyenv Python 版本。要关闭虚拟环境,请在命令提示符中输入deactivate

第 6 行是可选的,它升级了之前设置的 Python 虚拟环境中的pip命令版本。如果pip检测到你正在运行较旧版本,它将打印出一条消息,告诉你正在运行较旧版本,并建议你更新该版本。如果你愿意,可以忽略这条信息并跳过第 6 行。我包括它是因为我不想再看到那条消息。

A.3 设置 Visual Studio Code

使用文本编辑器就可以编写 Python 代码。在编写 Python 程序代码的上下文中,文本编辑器正是如此——一个不会添加或减少你键盘上输入的任何内容的编辑器。Windows 上的记事本应用程序和 Mac 上的 Textedit 应用程序都是简单、功能强大的文本编辑器的例子。

微软 Word 虽然是一个功能强大的编辑器,但并不是创建纯文本的好选择,因为它的默认设置是保存你输入的内容,而不附带大量关于格式、字体选择等方面的额外信息,这些信息与程序代码无关。任何文字处理应用程序都会使编写纯 Python 代码比使用文本编辑器更复杂。

Python 程序只是具有.py文件扩展名的普通文本文件。许多文本编辑器都理解具有.py扩展名的文件是 Python 文件。这些编辑器会在你按下回车键结束包含块指示字符(`:`)的代码行时自动提供空白缩进。它们还会对你的代码进行语法高亮,这意味着改变文本颜色以突出显示 Python 关键字、字符串、变量以及关于你正在创建的程序的其他视觉提示。

微软提供了一款名为 Visual Studio Code(简称 VSCode)的免费代码编辑器,它是一个用作 Python 程序编辑器的极佳选择。除了是一个出色的编辑器外,它还提供了许多扩展,使其在开发 Python 应用程序时成为一个更好的工作系统。您将安装其中一个 Python 扩展,将 Visual Studio Code 转换为一个完整的集成开发环境(IDE)。此扩展提供了 Python 语法高亮和其他特定语言的功能。最强大的功能是能够在编辑器内交互式地运行和调试您的代码。

A.3.1 安装 Visual Studio Code

在本书出版时,您可以从 code.visualstudio.com 下载 Visual Studio Code。此链接将带您到微软网页,其中包含下载 VSCode 的 Windows、Mac 和 Linux 链接。Visual Studio Code 是一个独立的应用程序,与微软的更大型的商业应用程序开发系统 Visual Studio 不同。

对于 Windows 和 Mac 的安装,过程相对简单:点击链接下载安装程序,然后双击下载的文件来安装应用程序。对于 Linux 安装,点击链接,根据您的 Linux 版本,选择将运行安装过程的包管理器。安装完成后,将 VSCode 应用程序图标添加到 Windows 任务栏、Mac 桌面或 Linux 桌面/应用程序菜单,以便更容易访问。

A.3.2 安装 Python 扩展

一旦安装了 VSCode,您需要添加微软的 Python 扩展。此扩展提供语法高亮、IntelliSense、调试功能以及许多其他功能。按照以下步骤操作:

  1. 打开 VSCode 应用程序。

  2. 在 VSCode 中,打开扩展。

    • 点击扩展图标。

    • 选择视图 -> 扩展。

  3. 在“市场搜索扩展”文本框中输入“python”,然后按回车键。

  4. 请确保使用微软提供的 Python 扩展。

  5. 在第一个项目上,点击安装按钮。

创建一个具有 .py 文件扩展名的新的 Python 代码文件,并输入一些 Python 代码。VSCode 是否对代码进行了语法高亮?您是否能够从 VSCode 内保存和运行代码?

到目前为止,VSCode 已配置为与 Python 文件一起使用。花些时间阅读应用程序右侧窗口中关于 Python 扩展的文档。

A.3.3 其他有用的扩展

除了微软提供的 Python 扩展外,还有其他有用的扩展可用:

  • Python Docstring Generator—当您在函数或方法定义后立即输入三引号 (""") 并按回车键时,自动生成 Python 文档字符串注释。文档字符串是一个模板,以易于导航的方式包含所有参数和返回值,使记录代码变得更加简单。

  • Code Runner—使从 VSCode 内部的右键菜单中运行 Python 代码文件变得更加容易。

  • DotENV—为 .env 文件添加语法高亮,这些文件是本地环境文件,用于在代码文件之外初始化环境变量。

  • Better Jinja—为 Jinja 模板语言添加语法高亮,这是 Flask 网络应用程序的默认模板语言。

A.3.4 从命令行开始

VSCode 是一个强大的图形用户界面应用程序,可以通过双击其图标或从视觉菜单中点击其名称/图标来启动。这是桌面视觉工具的常见用例,但并不是启动应用程序的最有帮助的方式。因为本书的所有示例代码都将使用 Python 虚拟环境,所以在启动 VSCode 之前创建虚拟环境很有用。同时,能够在包含虚拟环境的项目目录中从命令行启动 VSCode 也很有帮助。以这种方式启动 VSCode 将帮助它“看到”并使用目录中的虚拟环境。要配置 VSCode 以在您所在的目录中从命令行启动,请执行以下操作:

  • Windows:

    • 安装 VSCode 后,系统已经配置为通过 PowerShell 命令提示符输入以下命令从当前目录打开 VSCode:

      code .
      
  • Mac:

    • 启动 VSCode。

    • 导航到命令面板(视图 -> 命令面板)。

    • 输入 shell 命令以查找 Shell 命令:在 PATH 中安装“code”命令。

    • 点击上面的链接。

  • Linux:

    • 安装 VSCode 后,系统已经配置为通过终端输入以下命令从当前目录打开 VSCode:

      code . 
      

A.3.5 开始一个项目

在安装并配置 VSCode 从命令行启动后,您可以按照以下步骤创建项目目录并启动 VSCode 以使用它。以这种方式启动 VSCode 用于本书中的所有示例项目,并且通常是一个创建自己项目的优秀方式。按照以下步骤创建新项目:

  1. 打开终端或 PowerShell 并到达命令提示符。

  2. 将您的目录更改为您想要创建项目的位置。

  3. 在该目录中,创建一个新的目录 mkdir <项目名称>

  4. 将您的目录更改为新创建的 <项目名称> 目录。

  5. 对于 Mac 和 Linux,输入以下命令:pyenv local 3.10.3.

  6. 输入以下命令:python -m venv .venv.

到目前为止,项目已配置为使用安装在 .venv 目录中的 Python 3.10.3 虚拟环境。.venv 名称通常用作本地安装的虚拟环境的目录名称。您可以通过以下步骤激活您的本地 Python 环境,并检查它是否正常工作:

  1. 按照以下方式激活您的 Python 虚拟环境:

    Mac 和 Linux,输入以下命令:source .venv/bin/activate

    Windows,输入以下命令:.venv\Scripts\activate

  2. 您的命令提示符将添加前缀 (.venv).

  3. 输入以下命令:python –-version.

  4. 系统将响应为Python 3.10.3

现在你的 Python 虚拟环境已经创建在你的项目目录中,当你从命令行启动时,VSCode 会自动发现它。在命令行中输入code .将会在项目目录中打开 VSCode。因为你在目录中创建了一个 Python 虚拟环境,VSCode 会提示你是否想要使用该环境,你应该回答“是”。按照以下步骤继续配置 VSCode 以与你在项目中创建的任何 Python 代码一起工作:

  1. 打开命令面板(视图 -> 命令面板)。

  2. 在文本框中输入选择解释器并点击Python: 选择解释器

  3. 在出现的弹出菜单中,选择你创建的虚拟环境,如下所示:

    Windows: .venv\Scripts\python.exe

    Mac 和 Linux: .venv/bin/python

  4. 创建一个新文件(文件 -> 新文件)。

  5. 在生成的文件编辑器窗口中,输入以下代码:

    print("hello world")
    
  6. 将文件保存为 Python 文件(文件 -> 保存 -> test.py)。

    VSCode 将显示一个提示通知你,“Linter pylint 未安装。”点击安装按钮。VSCode 将使用你的虚拟环境中的pip命令来安装 PyLinter。Linter 是一个预运行时工具,它检查你的代码中的语法错误、错误、不寻常的结构等,并且是每个项目都应安装的有用工具。

  7. 右键点击test.py编辑器窗口并选择在终端中运行 Python 文件。

注意:可能感觉让 VSCode 启动并运行需要做很多工作,而且你只是给自己又增加了一个需要学习的工具。这些都是真的,但我鼓励你使用并学习这个工具。IDE 是一个强大的效率提升工具,并且作为开发者,它能给你带来真正的优势。

在最后一步之后,你应该会在 VSCode 中打开的终端窗口中看到"hello world"被打印出来。恭喜你,你已经在强大的项目环境中运行了你的第一个 Python 程序!

A.4 一些建议

需要强调的一个更多概念是你最有用的工具——你自己。花时间优化你作为开发者的工作方式。拥有一个你熟悉的合适开发环境是强大的,但建立一个高效的个人工作环境是值得花时间的。如果你打算为自己、专业或两者都开发软件,你将花费相当多的时间来做这件事。一个合理的桌子、一把舒适的椅子、一个好的显示器和合适的键盘都是这个环境的一部分。

这最后的建议是基于多年作为开发者的工作经验,无论是单独工作还是团队协作。(如果这对你不适用,请随意忽略。)花时间使你与代码之间的界面尽可能快和无缝。学会盲打并使用键盘快捷键,而不是使用鼠标。我妈妈在我初中时让我参加了打字课,那时候是 IBM Selectric 打字机时代。我可以告诉你,我当时并不太高兴,并且多年都没有感激它。现在,我认为这是她给我的许多礼物之一,我每天都在为此感到感激。

作为开发者,你将需要写很多除了代码之外的东西:文档、维基页面、网页、演示文稿、笔记、电子邮件——这个列表很长,而且还在不断增长。通过你的双手迅速而准确地把你 thoughts 发送到屏幕上,可以快速地移除机械操作,让你的思想和想法流畅地流动。

posted @ 2025-11-18 09:33  绝不原创的飞龙  阅读(16)  评论(0)    收藏  举报