UCB-CS169-软件工程笔记-全-

UCB CS169 软件工程笔记(全)

001:课程介绍与软件工程概述

在本节课中,我们将学习软件工程的基本概念,了解它与普通编程的区别,并初步认识CS169课程的结构、目标以及你将使用的工具和流程。

概述

软件工程不仅仅是编写代码,它更关注如何系统化地构建、维护和演化软件,以满足用户需求,并确保在团队协作中的高效与质量。本节课将为你勾勒出这门学科的轮廓。

课程与讲师介绍

大家好,欢迎回到秋季学期,欢迎来到CS169课程。我是Michael,计算机科学系的新讲师。我三年前从伯克利毕业,研究方向是计算机科学教育,过去几年在Gradescope工作。我相信你们都使用过这个工具,它与你们的成绩息息相关。

CS169是一门关于软件工程的课程。我们将深入探讨如何构建软件及其背后的流程。课程中包含大量编码实践,但代码本身并非本课程的唯一重点,我们今天就会讨论这一点。

软件工程的历史一瞥:玛格丽特·汉密尔顿

在课程中,我们会不时穿插一些计算机历史,这既能阐明软件工程作为一门学科和实践的历史,也包含了许多普遍有趣的知识。

这是一张玛格丽特·汉密尔顿的照片。她是原始阿波罗软件工程团队的成员之一。我们来看一个简短的视频片段(假设设备正常播放)。

(视频片段描述了阿波罗任务中的场景,可以听到“警报”一词。)

在这个片段中,可以听到“警报”一词。这涉及到计算和计算历史中一个有趣的点。当时的情况是:在阿波罗任务中,飞船的天线正在处理各种着陆信号。所使用的计算机是1969年的处理器,受限于当时无数的计算资源,它无法同时处理所有需要处理的数据。

玛格丽特·汉密尔顿的贡献之一是提出了动态资源调度的概念。当时发生的情况是,一名宇航员让一个天线信号保持开启状态,计算机需要处理它。但计算机选择放弃该任务,转而专注于处理着陆机动数据,以确保能够成功在月球着陆。计算机发出的“警报”正是在提示它正在丢弃某些任务。

这个小故事说明,当我们审视软件工程这门学科时,涉及的不仅仅是我们编写的代码,还包括我们必须用来处理这些代码的工具和思想。

此外,阿波罗任务使用的所有代码都有一个GitHub仓库,属于公共领域,因为它是一个由纳税人资助的项目。如果你有兴趣查看一些旧代码,这会非常有趣。

这张照片是玛格丽特·汉密尔顿与麻省理工学院软件工程团队的合影,充满了1960年代风格。他们编写了在阿波罗任务中运行的大部分软件。

软件工程 vs. 编程

那么,当谈到软件工程与一般编程的区别时,大家有什么想法?软件工程可能关心哪些方面?

  • 需求:没错。我们构建软件不仅仅是为了自己,可能还有客户,可能需要遵循规格说明。这肯定是其中的一部分。
  • 编程:当然。这肯定涉及编程,如果没有大量的编程实践,就不能称之为计算机科学课程。
  • 可扩展性:我们可以讨论它需要多大规模,以及是否总是需要那么大规模。
  • 团队合作:是的,绝对正确。作为团队的一部分工作是这里最大的区别之一。团队合作包含多个层面:这可能是你和本课程中的其他五位队友;可能是你毕业后实习或工作中的直接团队、整个组织;甚至可能是你在GitHub上参与开源项目,与世界各地、可能说不同语言的人们协作。多人协作是一个关键部分。

软件工程的核心

这是一张大卫·帕纳斯(David Parnas)的照片,他对软件工程领域有重要影响。课程中会出现一些人物和照片,用以说明某些思想的来源。本课程的重点不是记忆这些人名,而是将其作为背景,指出我们并非凭空发明所有这些材料。

这是图灵奖得主弗雷德·布鲁克斯(Fred Brooks)的一句名言,它指出了一个非常有趣的部分:“将分别编写的程序组合起来,并使它们适合未曾编写它们的人使用。”

我认为其中有趣的观点是:在生产中使用的软件不仅仅是CS61A中那样独立的代码片段。虽然在本课程后期你们会接触到更复杂的项目,但通常来说,它都是某种封装好的模块。软件会随着时间的推移而演化。“为未曾编写它们的人所用”也是一个关键组成部分,这将是我们关注的重点之一。

围绕代码的所有部分都是为了帮助实现这一方面。值得注意的是,“未曾编写它们的人”会一次又一次地出现,包括三个月后的你自己。在课程项目和实习中,你通常没有机会在数月甚至数年后回头查看自己的代码。但当这种情况发生时,你并不总是拥有相同的上下文。因此,拥有围绕代码的工具、流程和基础设施,将有助于你在需要修改、维护和调整时更加轻松。

优秀软件工程师的特质

大约六七年前,当这门课程开发时,David Patterson和Armando Fox教授调查了许多与EECS部门合作的行业伙伴,询问他们需要什么。同时,大约三年前,微软和华盛顿大学进行了另一项调查,旨在了解对个体软件工程师有哪些方面的要求。

以下是调查中强调的一些方面:

个人层面

  • 自我驱动:不断提升自我,是什么让你在团队中表现出色。

团队层面

  • 给予反馈:如何在团队中有效运作。
  • 创造安全空间:这在教育中也很重要。当有人对某个过程不确定时,你如何回应问题?确保这是一个可以安全提问的空间。在本课程的项目中,任何时候你都可能遇到完全不知道如何做的事情。你向助教或我提出的问题,我们可能也没有现成的解决方案。提出各种想法是完全没问题的。在软件工程团队中,这是一个重要的方面。希望你们在小组项目合作乃至整个课程中都能培养这种氛围。

技术层面

  • 技术背景:当然,如果你在编写代码,拥有适当的技术背景很重要。在伯克利学习到这个阶段,你们已经经历了两年密集的低年级课程,可能还有一两门高年级课程,你们已经在技术能力上打下了良好基础。但这仍然是另一个重要部分。

决策与流程

  • 决策制定:你经历了哪些步骤?你是否在批判性思考?你是否帮助记录和定义这些流程?

这里还有很多内容,但这些是流程每个环节的要点。这是一项关于什么造就了优秀软件工程师的调查的亮点。

本课程的精髓

可以将本课程的精髓提炼为一句话:纪律严明、以客户为中心、敏捷的软件即服务工程

  • 纪律:指软件工程这门学科。
  • 软件即服务:指我们将要构建的内容。

你们将要从事的项目是基于数据库的Web应用程序,旨在解决某些客户需求,并部署到云端。如果你有过实习经历,可能接触过类似的东西。这可以说是现代科技初创公司(甚至已非初创公司)的支柱,也是你们日常生活中使用的大多数项目或程序的核心组成部分,它们现在都是软件即服务或具有其组件。

课程预期与前提

我们对本课程的预期设定如下:

  1. 假设你具备良好的编码能力:你们已经在伯克利的学业中取得了进展,这应该不是大问题。
  2. 假设你拥有GitHub或GitHub学生账户:你不需要GitHub的学生专属功能,但如果你注册了,会有很多很棒的免费福利。如果你还没有GitHub账户,可以创建一个,你们将在那里进行项目工作。GitHub将是本课程的重要组成部分,既用于项目管理,也是你们未来很可能使用的工具。即使不是GitHub,在你未来的工作中,也很可能会使用类似的软件。

本课程的目标是为你们未来在行业环境(以及许多研究环境)中构建超越周末黑客项目的软件做好准备。

本课程会教特定框架吗?

有人想猜猜这个问题的答案吗?本课程会教我JavaScript、React、Ruby on Rails、Django或任何当下流行的框架吗?

答案是:不会

原因是框架来来去去,变化迅速。当然,我们将要使用的Rails本身已有超过十年的历史(现在大约15年了),它们并非完全转瞬即逝。但本课程的目标不是教你们Rails的机制。你们绝对有机会学习它们,如果你们深入项目,这将是真正动手实践框架的最广泛方式。本课程将侧重于服务器端组件。

当我修读CS169时,Angular是当时热门的JavaScript框架。一些项目小组深入其中,用Angular构建了非常棒的体验,因为他们想学习它,并且这符合他们项目的功能需求。虽然我们将使用Rails作为本课程的载体,但目的未必是帮助你们成为Rails专家。

课程必要组成部分

就本课程的必要组成部分而言,你们确实需要两者:

  • 讲座是必要但不充分的
  • 阅读教科书也是必要但不充分的

教科书旨在比讲座更深入地探讨特定主题。我们也会有一些新的草稿章节提供给你们。请务必阅读教科书。它并不厚重,旨在简洁扼要,但包含许多很好的例子。可以说,当我还是学生时,这是我真正阅读过的少数教科书之一。我确实喜欢阅读这本教科书,而且它后来有所改进。

学生通常缺乏的技术技能

这是一张我本想早些展示的幻灯片:学生通常缺乏哪些技术技能?当与雇主交谈,询问学生实习或毕业时缺乏哪些经验时,会得到以下反馈:

  1. 遗留代码:在你们目前的学业生涯中,基本上处理的是要么有脚手架的家庭作业(填充部分代码),要么是从头开始的新项目。CS61B中,你们几乎是从白板开始编写所有内容。这是一种可行的学习方式,每个项目都以某种形式从空白开始。但你们真正需要的一项技能是处理遗留代码。本课程的许多项目将从其他169学生接手、正在被客户使用的项目开始。你们将延续该应用程序的生命,处理可能由过去几年里6、18甚至30名其他学生编写的代码。你们基本上需要假设这些编写者不存在(尽管他们确实存在,已经毕业工作),但你们不能直接去问“这段代码是如何工作的?”项目仓库中的内容就是你们需要处理的。遗留代码是一个方面。你们中的一些人将从事新项目,但大多数人将处理遗留项目。我们设计的家庭作业旨在让你们获得这种经验。

  2. 与非技术客户合作:这也是一个巨大的组成部分。CS169项目部分的一个优点是,你们合作的团队是非营利组织、校园组织、非政府组织等,他们需要构建一些软件,但并不总是具备描述需求的技术经验。他们不会告诉你们“这是我想要的用户模型模式”。他们会告诉你们一系列需求,然后由你们去实现。你们中的少数人将为技术客户做项目,我可能会是一两个项目的客户,你们的助教也可能是一两个项目的客户。当然,你们肯定会有技术客户的项目,但那里的目标是我们仍然不会告诉你们如何规划用户模型。与技术客户合作有其自身不同的挑战,但我们会尽可能以非技术的方式行事。Fox教授也会是几个项目的客户,当他作为客户时,他也以同样的方式工作。

  3. 测试:这绝对是每个人都说初级软件工程师缺乏经验的事情。本课程将涵盖测试的各个方面、不同类型的测试,以及如何使测试我们的应用程序更容易。我们将通过家庭作业贯穿始终,在你们的项目中,当开始调整他人的遗留代码时,你们将亲密接触他人的测试,并可以判断这些测试用例是否有帮助。总的来说,我们希望它们会非常有帮助。

课程项目主题

本课程的项目主题,我认为是作为一所公立大学使命的延伸:行善致远

你们将在本课程中学到很多,而你们承担的项目将有助于外部世界。这些团队、这些其他团体通常需要自动化流程、跟踪数据等软件特别擅长的事情。通过构建这些应用程序,你们可以真正帮助一个团体以他们肯定没有数百万美元去雇佣咨询公司来构建应用程序的方式,完成他们的使命。

项目流程概述

以下是你们项目概况的两张幻灯片概览。

技术流程

  • 有存储在服务器上的仓库代码,通常也存放在GitHub上,并最常部署到Heroku。如果你们没用过Heroku,它是一个非常易于使用的软件部署平台。
  • 在这个版本中,这是应用程序的典型运行实例,其中许多至今仍在使用。你们将接手其中一些项目,该应用程序将在学期中投入使用。
  • 你们拥有自己团队的副本(在GitHub上称为Fork,但术语不重要)。本学期,你们的团队将有一个规范的代码仓库。你们每个人将在自己的本地副本上工作,并使用一种称为“基于分支的功能开发”模型。
  • 在一个六人团队中,任何给定时间可能有两三个不同的功能在同时进行。你们将学习如何使用Git。你们不需要成为Git专家,甚至不需要太多高级知识,但如果还没有经验,将会获得相关经验。
  • 这样做的理念是,你们将有机会观察代码库如何演化,以及多人如何同时在其上工作,可能朝着相似但也可能不同的方向调整各个部分。你们将与多个队友一起完成这项工作。
  • 随着学期进展,你们将进行“拉取请求”。在项目的每个迭代完成后,你们可以说“我完成了”。你们将有机会与助教团队讨论,然后将其合并回主仓库。
  • 在此过程中,我们将使用一些行业标准工具:
    • Travis CI:一个允许你们使用持续集成框架的工具,帮助你们尽可能多地测试代码。每次推送到GitHub时,你们都可以运行一系列测试并获得结果。
    • Code Climate:一个包含Linter和静态分析器等功能的工具。它给出一个分数,这个分数将松散地成为你们项目成绩的一部分。重点不是必须达到某个分数(我记得是4.0,因为它过去实际上是字母等级),而是你们是否采取措施提高代码库的质量。好处在于,这是机器人执行适合机器人的任务:你们是否遵循现有代码库的约定?这不是严格关于项目分数,我们不是说你们必须始终保持100%的分数,而是关于维护健康的代码库。
    • Coveralls:一个类似的应用程序,试图测量测试覆盖率。所有这些工具都旨在作为工具使用,它们都有各自的怪癖和有时毫无意义的指标。但确保编写代码时具有对等性、并且有测试覆盖的总体理念将是重要的一部分,这些工具将帮助你们反映这一点。
  • 你们会在许多开源项目上看到这样的徽章,你们将有机会在自己项目的README中看到它们。
  • 然后,假设一切顺利,你们将在整个学期中将应用程序的第二个副本部署到Heroku。这将是一个实际体验软件开发完整生命周期的机会。
  • 一旦代码部署,你们的客户将就进展情况提供反馈。他们将与他们的应用程序版本进行交互。希望这些回头客中的大多数也能体验上学期版本的应用,尽管并非全部如此,有时会是该团体的新联系人。他们将能够告诉你们发生了什么变化、感觉如何。有时他们会为你们发现错误。但他们是客户,你们为他们构建东西,但他们也不一定是你们的QA团队。
  • 在学期末(如果一切顺利可能更早),你们的工作将被合并回那个实时应用程序中。这样做的目的是让每个团队都有实际投入现实世界、正在使用的软件。本课程的许多项目已经持续了数年,有些可能只持续一个学期,但目标是拥有持久的东西。

协作流程

  • 这是这个过程在人际方面的样子。你们将开始与客户交谈。
  • 然后你们可能会进行某种形式的原型设计。这可能是一个草图,也可能是一个PowerPoint幻灯片。我们将讨论用于此的工具。
  • 接下来,你们将把那个原型转化为用户故事。例如:“当用户登录时,应该发生X”;“当用户创建新条目时,应该发生Y”。可能是跟踪某种客户互动的功能。
  • 你们将与客户一起制定这些用户故事。
  • 然后我们将使用一个名为Cucumber的工具,帮助你们以非常类似英语的方式将这些用户故事编写成有用的软件测试。这样,当查看测试文件时,你们将对软件预期行为有清晰的描述。你们将能够把这些用户故事转化为测试。
  • 接下来,我们将尝试让你们进入“测试优先”的思维模式。先写测试并不总是自然的,如果你们在实习中做过,那很好,但我预计大多数人没有将测试优先的软件开发作为一项纪律。给它一些时间。你们将编写测试,它们会失败,你们会看到一堆红点,这感觉从来都不好。但随着构建功能,这些东西将通过,看到东西从红变绿也是一种很棒的感觉。
  • 在此过程中,你们和你们的客户将使用Pivotal Tracker来跟踪正在构建的这些故事。
  • 最后,你们将部署。部署将在整个项目中多次进行,只要你们准备好了某些东西。我们将以迭代方式工作,但你们可以随时将代码发布出去。我们鼓励你们只要完成这个过程就持续测试和部署代码。团队工作越快,就有更多机会将代码推送到实时服务器上并看到它运行。
  • 在每个迭代结束时,我们基本上重新开始。希望你们在本课程中完成四次这样的迭代,也就是四个为期两周的迭代。但这实际上取决于课程的实际流程和速度。

在此过程中,你们将获得一些经验:处理遗留代码(构建一个功能后,可能等一个月再去调整它;调整过去学期他人的代码),学习一些设计模式。我们将更具体地讨论这些,但理念是帮助你们以结构化的方式编写代码。

这就是用两张幻灯片概括的软件工程。内容很多,但不必过于担心细节,我们的目标是随着课程深入,让你们自然融入这个过程。

课程期望与建议

关于本课程,有几张幻灯片是关于“如何在CS169度过糟糕时光”和“如何做错所有事”的:

  • 跳过讲座(反正有网络直播,在线看就行)。
  • 不做作业(因为只是作业,都是虚构的、小型的,没什么可学的)。
  • 独自工作(你们将与同学竞争,因为一切都是按曲线评分的,所以不要帮助同学)。
  • 不需要学习新东西(反正你们已经实习过了)。
  • 忽略流程(步骤不重要)。
  • 只关注分数(学习不重要,一切都会评分,只尝试考试,别担心其他)。
  • 作弊(如果作弊,我们可能抓不到)。

我特别要强调关于分数的一点。在本课程中,你们的项目成绩不仅基于测试是否通过、Code Climate是否健康,还基于所有投入该工作的点点滴滴:你们是否与团队合作?是否努力跟进GitHub?是否用用户故事记录事物?是否编写测试?这门课程的评分有时会有点麻烦和混乱,但核心理念是:在整个课程中,对你们成绩影响最大的是参与所有可能的不同领域。

我今天没有带答题器基站,所以今天不会有任何答题器问题,但我们以后会做,并讨论一下。在项目评分中,它是非常全面的,会有关于团队成员工作情况的调查,这会影响他们的成绩。你们不能仅仅因为在学期最后一周发生争执就说“嘿,我的队友很糟糕,我们不想让他们通过”。我们会考虑所有因素,包括你们编写的代码、队友的调查、家庭作业的练习。当然,考试也是本课程的一个组成部分,但这里的目的是确保你们真正拥有全面的经验,因为当你们在实习或专业构建软件时,每一部分都很重要,任何一个环节出错都可能导致项目脱轨。

请记住,这里的目的是平衡讲座、编码、项目工作等各个方面。对于使用网络直播的同学,有一些幻灯片标有“结束”,如果你们复习网络直播,这些是暂停和反思的地方,它们也将讲座按主题分块。

总结

本节课我们一起探讨了软件工程的基本定义,了解了CS169课程的目标、结构以及它将如何通过实际项目帮助你们掌握构建高质量、可维护软件所需的技能、工具和协作流程。记住,软件工程的核心在于系统化的方法、团队协作以及对不断变化的需求的适应能力。

下节课我们将开始深入探讨具体的软件开发方法论。

002:敏捷开发、遗留代码与软件即服务

在本节课中,我们将要学习敏捷开发的核心思想,理解遗留代码与高质量代码的区别,并探讨软件即服务(SaaS)在现代软件开发中的重要性。我们还将介绍结对编程、测试策略以及如何高效学习新语言和框架。

课程管理与公告

请密切关注 Piazza 论坛。本周晚些时候,我们将发布团队匹配调查问卷。如果你仍在自行寻找小组,我们会尽力让以团队形式提交的小组保持完整。请记住,你必须以项目团队的形式参加讨论课。

今天以及下周课程结束前,你可以参加任何一节讨论课。如果你随机选择,所有讨论课都对你开放。如果你知道自己只能参加某一节讨论课,请尽量参加那一节。我们会尽力协调每个人的时间安排。

今天课堂上将使用 iClicker 进行互动。如果你还没有 iClicker,请不要过于担心。它们用于参与度评分,只会对你的成绩有帮助。即使错过几次,也没有关系。

第一次作业已发布在课程网站上。我们将通过 GradeScope 提交作业。自动评分器尚未上线,预计将在未来几天内,可能在周末,准备就绪。作业说明已发布,有很多内容可以开始着手。

今天我们将讨论结对编程。你可以在作业中结对完成,但每个人都需要提交自己的作业版本。每个人都应提交自己的作品。如果你与他人合作,请在你编写的文件顶部注明合作者信息,以便我们在检查时了解情况。

我们鼓励你在作业中使用结对编程,向同伴学习并教授他人,这是最好的学习方式。

关于选课事宜,本课程没有提前退课的截止日期。如果你不确定,请在周五前做出决定。我们可能会根据项目和名额情况,稍微扩大候补名单。但如果你考虑退课,请尽快操作,以便候补名单上的同学能够选上。

另外,一些同学在使用通往 GradeScope 的 Bcourses 链接时遇到问题。如果出现错误提示,请发送邮件至 help@gradescope.com,并抄送我,或者在 Piazza 上发帖说明。这个问题似乎随机出现,我们正在努力调试。如果链接无效,你也可以直接访问 gradescope.com,点击“学校凭证”,使用 CalNet 登录。你应该能正常进入 GradeScope。所有人都应已注册本课程,但如果遇到问题,请告知 GradeScope 团队和我,我们正在尝试解决。这就是软件开发的生活,也许学期晚些时候,这会成为一个有趣的课程案例。

今日主题:持续尝试

今天的主题,也是本课程中软件工程的一个普遍主题,是“持续尝试”。

Martin Fowler 是一位多产的软件工程作者和顾问,他有一个著名的说法:“如果某件事让你感到痛苦,那就更频繁地去做。” 这听起来有点违反直觉,因为如果某件事很痛苦,听起来并不愉快。但持续尝试会让它变得更容易。同时,这个理念也鼓励你去优化这个过程,使其不再痛苦。

例如,如果获取客户反馈很痛苦,那就更频繁地获取反馈。如果编写测试很痛苦,那就更频繁地编写测试。因为每一次迭代都会让这个过程变得更容易。这将是贯穿本课程的元主题。

回顾与敏捷开发简介

上节课我们讨论了“计划与文档”模型以及螺旋式生命周期。那么,除了计划与文档和螺旋式开发,还有哪些替代方案呢?我们能否以其他方式有效地构建软件?当然,我们能否以一种系统化的方式,而不仅仅是随意编写代码,来构建能够持久的软件?答案是肯定的,但这需要一些思考和纪律。

敏捷宣言于 2001 年提出,它旨在采纳迭代、获取客户反馈、以更增量的方式开发软件等理念。它更倾向于团队、个人和客户,而不是严格的过程和工具。你会发现,在工业界,敏捷常常与严格的过程混为一谈,但宣言的本意是宽泛的,不一定非常规定性。

敏捷宣言的价值观包括:

  • 个体和互动 高于 流程和工具
  • 工作的软件 高于 详尽的文档
  • 客户合作 高于 合同谈判
  • 响应变化 高于 遵循计划

这并不是说右边的事项没有价值,而是强调更应重视左边那些无形的部分。敏捷宣言是一种哲学,而非一套严格的规则。

敏捷开发的核心实践

敏捷开发的核心是拥抱变化和持续开发。在软件工程中,你通常有一个工作计划。在计划与文档方法中,这个计划可能从项目开始持续到结束。而在敏捷中,目标是将迭代周期缩短到一到两周。有些公司采用六周迭代。在本课程中,我们将进行大约两周的迭代。每次迭代,你都有时间开发出一些可用的、功能性的东西,并获取反馈,这确实是迭代的目标。

在每个迭代步骤中,你将与客户互动、编写软件、测试软件并将其部署到 Heroku。我们将学习以下具体实践:

  • 测试驱动开发:在编写代码之前先编写测试。我们将使用 Cucumber 这样的工具,它允许你用类似英语的语法编写测试。
  • 用户故事:从用户角度(而非实现角度)描述项目中应该发生什么的工作项。你的客户将帮助你确定这些内容。
  • 速率:衡量团队在特定时间段内交付工作量的指标。它可以帮助你更好地规划未来。

本课程大致遵循的敏捷变体是极限编程。它遵循“如果某件事让你感到痛苦,那就更频繁地去做”的理念。具体包括:

  • 短迭代:尽可能缩短迭代周期,例如以周为单位而非年。
  • 简单性:做最简单可行的事,即构建最小可行产品。
  • 持续测试:如果测试是好的,那就一直测试。测试驱动开发是其中的重要组成部分。
  • 持续代码审查:结对编程是一种在编写代码时进行即时代码审查的方式。

敏捷的现状

自 2001 年以来,敏捷宣言在某些圈子里颇具争议,但过去几年已变得非常主流。几乎所有大型科技公司都以某种形式实践敏捷,尽管细节各异。极限编程、Scrum、看板和精益开发都是敏捷编程的变体。Scrum 是目前比较流行的一种。

遗留代码与高质量代码

接下来,我们讨论遗留软件与高质量软件之间的区别。这二者并不一定是对立的。

一个常见的问题是:在软件预算或维护时间中,有多少比例用于功能增强,多少用于修复缺陷?大多数人可能会选择“修复缺陷”作为首要选项。但答案是,用于增强软件的时间比修复缺陷的时间更多

大约 60% 的维护成本最终用于软件的功能增强。这些增强可能源于新的功能请求,或者最初未预料到的挑战。需求会随时间变化,这些变化本身并不是软件中的缺陷,但需要被解决。因此,随着软件的生命周期延续,它会不断演进和适应。

因为遗留代码很重要。如果 60% 的软件工作是维护,意味着我们大部分时间都在处理已经存在一段时间的代码,可能是别人编写的,可能已有 5 年、10 年或 20 年的历史。在本课程中,你们的许多项目将基于往届 CS169 学生编写的项目进行修改和增强。

有句名言说:“旧的硬件会过时,旧的软件每晚都会投入生产。” 你们使用的每个网络应用,其核心部分可能都包含自项目初期编写且从未重写的代码。因此,我们如何处理遗留代码至关重要。

遗留代码是指满足需求但需要演进和适应的代码。
高质量代码是指既能解决问题,又易于演进和适应的代码。

本课程的目标就是练习编写易于适应、易于演进并能持续存在的高质量代码。高质量代码不仅指 Ruby 文件中的代码,还包括整个项目:测试用例、视图、文档等。

学习新语言和框架的建议

在软件工程生涯中,你使用的技术栈和工具会不断变化。本课程的一个目标是快速学习新语言。我们的建议是:理解新语言的应用程序架构,然后理解框架与语言之间的映射关系。

如果你刚接触 Ruby,可能会对哪些是 Rails 特性、哪些是 Ruby 标准特性感到困惑。Rails 为 Ruby 添加了许多使其更易用的特性。前几次作业将只涉及 Ruby,然后我们会在此基础上加入 Rails,这有助于你理清头绪。

以下是学习新语言时的一般建议:

  • 类型系统:语言是动态类型还是静态类型?强类型还是弱类型?
  • 基本语法:原始数据类型、循环、控制流结构(如 if-else)。
  • 函数与过程:如何定义和使用方法。
  • 抽象与封装:语言如何处理这些概念?是否有类?私有/公有方法的区别?
  • 惯用法:这是较难的部分。了解该语言社区的典型编码风格和模式。
  • 库与依赖管理:Ruby 使用 Bundler 和 Gemfile 来管理依赖。
  • 调试与测试:了解如何调试代码以及使用哪些测试库。

一些通用建议:

  • 在谷歌搜索时,问题描述越具体越好。可以将整个错误信息粘贴进去搜索。
  • 查看 Stack Overflow 等在线答案时,进行“元阅读”:这个答案合理吗?它被点赞了吗?评论是否表明它有帮助?注意答案的日期和版本。
  • 始终查阅官方文档,并注意版本号。
  • 如果不理解某段代码,先尝试调试,而不是直接粘贴运行。从互联网上复制脚本时,你并不知道它具体做了什么,直接运行可能带来安全风险。

结对编程

结对编程是指两个人同时进行编程,通常比一个人编程效果更好。许多公司都将结对编程作为日常实践。

在结对编程中,一个人作为驾驶员,负责实际编写代码;另一个人作为观察员领航员,负责提出问题、思考设计、检查代码。两人都高度参与这个过程。

以下是结对编程的一些注意事项:

  • 频繁交换角色:确保双方都有机会担任驾驶员和观察员。
  • 与不同的人结对:将资深工程师与初级工程师配对,是快速熟悉代码库的好方法。新人可能会提出资深人员忽略的问题。
  • 营造安全环境:不要评判同伴,结对编程应该是一个可以提出任何问题的安全环境。
  • 保持专注:结对编程对双方来说都可能很累,但这说明你们做对了。

研究表明,结对编程可以带来更高质量的代码,因为第二双眼睛可以帮助编写出更具可读性、更符合代码库风格的代码。它还有助于知识共享,降低“巴士因子”(即团队中唯一掌握某项关键知识的人离开所带来的风险)。一种更极端的变体是“频繁交换结对”,即不仅交换角色,还频繁交换结对伙伴,以便更广泛地了解整个代码库。

根据往届学生的反馈,结对编程能帮助避免低级错误(如难以发现的拼写错误),并使团队感觉更有凝聚力。

软件即服务

软件即服务在现代软件开发中无处不在。许多设备(如智能音箱)和应用程序(如浏览器、手机应用)都极大地依赖于云服务基础设施。

SaaS 带来了许多优势:

  • 数据备份与恢复:例如,手机数据备份到云端。
  • 统一的部署环境:开发者只需关注代码在云服务器上的运行,无需处理成千上万种客户端环境组合。
  • 持续部署与反馈:可以轻松、频繁地部署软件并获取反馈。
  • 可扩展性与可靠性:云服务提供商(如 AWS、Google Cloud、Microsoft Azure、Heroku)管理着庞大的数据中心,通过冗余和分布式设计,确保单点故障不会影响整体服务,并能根据需求弹性扩展资源。
  • 成本效益:通过规模经济,云服务提供商可以以更低的成本提供计算资源,用户只需按使用量付费。

这种模式也称为“仓库级计算”。像亚马逊最初为了应对购物季流量而过度配置资源,后来将这些闲置资源对外出租,从而诞生了 AWS。如今,即使是初创公司,也能利用云服务轻松地将应用扩展到全球范围。

当然,SaaS 也有挑战,例如需要设计软件以适应分布式扩展。但在本课程中,你们构建的应用规模暂时不需要担心这些非常复杂的分布式系统问题。

构建高质量软件

我们如何构建高质量软件?高质量软件意味着既能满足客户需求,又缺陷较少。同时,代码本身的质量也很重要——它是否易于开发和维护。

我们需要通过两种方式确保质量:

  • 验证:我们构建的东西正确吗?(是否按照预期的方式构建?)测试是验证的一部分。
  • 确认:我们构建了正确的东西吗?(这是客户真正需要的吗?)这需要与客户互动来确认。

你无法对应用程序中的每一条代码路径进行穷尽测试。我们的目标是将测试划分为不同层次,以合理高效的方式获得高度的信心。

我们将使用代码覆盖率作为衡量测试完备性的一个指标。它衡量的是测试套件执行了多少行代码。我们的目标是达到较高的覆盖率(例如 90% 左右),但追求 100% 可能不切实际,有时甚至会成为负担。

测试通常分为几个层次:

  • 单元测试:测试单个函数或方法。运行快,定位问题准。
  • 功能测试:测试跨越多个单元的模块或组件(如 Rails 中的控制器测试)。
  • 集成测试:测试整个应用栈的交互,模拟真实请求和响应。运行较慢,但能发现组件间集成问题。
  • 系统测试:从整体上验证软件是否满足规格说明。

理想情况下,应该混合使用不同层次的测试,在信心和效率之间取得最佳平衡。

通过简洁性实现清晰度

最后,我们讨论如何通过编写简洁的代码来提高生产力和清晰度。

随着软件需求增长,我们不能仅仅依赖硬件性能的提升(摩尔定律),还需要改变编写软件的方式。以下是一些技巧:

  • 代码生成:例如,使用模板生成 HTML,避免手动编写重复代码。
  • 通过简洁实现清晰:编写意图明确、描述性强的简洁代码。
  • 代码复用:通过函数、模块、库来复用代码。
  • 自动化与工具:利用自动化工具(如 Travis CI、GitHub)简化开发流程。

在 Ruby 和 Rails 中,有很多体现“简洁即清晰”的例子:

  • 动态语言特性:无需手动管理内存。
  • 富有表现力的方法名:例如,使用 assert_greater_than_or_equal_to 比一长串条件判断更清晰。
  • 强大的内置方法:例如,Rails 中 2.hours.ago 这样的时间计算非常直观易读。

编写代码时,应在简洁性和清晰度之间找到平衡。有时,多写几行但意图明确的代码,比一行晦涩难懂的“聪明”代码更好。

总结

本节课我们一起学习了敏捷开发的核心思想与实践,理解了处理遗留代码和编写高质量代码的重要性,并探讨了软件即服务模式的优势。我们还介绍了结对编程、分层测试策略以及如何高效学习新技术栈。最后,我们讨论了通过编写简洁、清晰的代码来提高开发效率。这些概念和技能将为你们在本课程及未来的软件工程实践中打下坚实的基础。

003:Web基础与API简介

在本节课中,我们将要学习Web的基础架构,包括客户端-服务器模型、HTTP协议以及API的基本概念。我们将通过命令行工具进行演示,帮助你理解网络请求和响应的实际过程。

课程管理与工具介绍

上一节我们介绍了课程的基本信息,本节中我们来看看一些关于代码重用和自动化工具的核心概念。

在软件工程中,代码重用至关重要。你应该始终思考:是重用现有代码还是编写新代码?函数和过程是重用代码的好方法。如果应用程序中有重复的逻辑,应将其提取出来。

以下是关于代码重用和工具的一些要点:

  • 利用库:当构建功能时,尤其是涉及安全性的功能(如登录系统),应优先使用成熟的库,而不是自己从头编写。这可以避免安全漏洞。
  • 接口与混入:在Ruby中,接口和混入是跨系统不同部分共享代码的工具。
  • DRY原则:不要重复自己。如果一段代码被重复编写多次,应将其提取为单一的真实来源。
  • 重构与TDD:在测试驱动开发中,有一个“红-绿-重构”的步骤。编写测试,重构代码,然后确保测试通过。
  • 自动化工具:对于重复的任务,应使用工具进行自动化。例如,make是传统的Unix工具,而Ruby中的rake(即“带R的make”)也是一个很好的例子。

工具虽然强大,但也有学习成本。在本课程中,我们将使用GitHub、Travis CI、Code Climate等工具。请记住,工具会不断演变和更替。

Web架构:客户端-服务器模型

上一节我们讨论了代码工具,本节中我们来看看Web作为一个整体是如何工作的。

Web本质上是一个客户端-服务器系统。下图展示了其基本架构:

[客户端 (浏览器/手机)] <---> [互联网] <---> [服务器 (如Rails应用)]

互联网位于你的应用(Web服务器)和客户端之间。本节课我们将聚焦于互联网层面,即应用如何连接。后续课程将深入Rails应用内部,探讨模型、视图和控制器。

从万米高空看,Web是客户端连接到服务器的模型。另一种模型是对等网络(P2P)。两者的主要区别在于:

  • 客户端-服务器:是集中式的、请求-响应导向的。客户端和服务器有明确的不同任务。
  • 对等网络:是去中心化的,所有节点执行大致相同的任务。

在Web中,当你的浏览器访问google.com时,它会向Google服务器发送一个请求,服务器则返回一个包含HTML和JavaScript的网页作为响应

TCP/IP与HTTP协议基础

上一节我们介绍了Web的宏观模型,本节中我们来看看支撑Web通信的技术细节:TCP/IP和HTTP协议。

TCP/IP是互联网通信的基石。

  • IP(互联网协议):负责寻址。IP地址(如128.32.xxx.xxx)标识网络上的设备。127.0.0.1(或localhost)指向本地机器。
  • TCP(传输控制协议):在IP之上提供了一个可靠、有序的字节流抽象。它确保数据完整、按序送达,让应用开发者无需处理数据包丢失、乱序等底层细节。

HTTP(超文本传输协议) 是构建在TCP/IP之上的应用层协议,用于Web通信。

  • 它是基于ASCII文本的协议,人类可读。
  • 一个HTTP请求包含:方法(如GET)、统一资源标识符(URL)、协议版本头部信息
  • 路由方法URI共同定义。例如,GET /bearsPOST /bears 是两个不同的路由。
  • 服务器收到请求后,返回一个HTTP响应,包含:状态码协议版本头部信息响应体

常见的HTTP状态码类别:

  • 2xx:成功。例如,200 OK
  • 3xx:重定向。
  • 4xx:客户端错误。例如,404 Not Found(未找到资源)。
  • 5xx:服务器错误。例如,500 Internal Server Error(服务器内部错误)。

实践:使用cURL探索HTTP

上一节我们学习了理论,本节中我们通过命令行工具cURL进行实际操作,观察HTTP请求和响应。

cURL是一个用于传输数据的命令行工具。我们将用它来访问网站 watchout4snakes.com

基本GET请求:

curl http://watchout4snakes.com

此命令会返回该网站的HTML内容,与在浏览器中访问看到的核心内容一致。

查看响应头信息:

curl -I http://watchout4snakes.com

-I 选项仅显示响应头部,其中包含状态码、服务器类型、内容长度等信息。

查看详细通信过程:

curl -v http://watchout4snakes.com

-v(verbose)选项显示详细的通信过程,包括发送的请求头接收的响应头。在请求头中,你可以看到 User-Agent,它标识了发出请求的客户端(如浏览器或cURL)。

URL结构与路由

上一节我们使用了cURL,本节中我们来解析一个URL的各个组成部分。

一个完整的URL示例:http://www.example.com:80/path/to/resource?q=cloud&lang=en#top

其组成部分如下:

  • 协议http://
  • 主机名www.example.com(通过DNS解析为IP地址)
  • 端口:80(HTTP默认端口,可省略)
  • 资源路径/path/to/resource
  • 查询字符串?q=cloud&lang=en(以?开头,包含参数)
  • 片段#top(指向页面内的特定位置)

再次强调,一个路由HTTP方法(如GET)和 URI(如/path)共同唯一确定。

服务导向架构

上一节我们探讨了单个应用如何通信,本节中我们来看看如何将大型应用拆分为更小的、可协作的服务,即服务导向架构。

早期的Web是静态的,后来发展为动态生成内容。Ajax(异步JavaScript和XML)技术使得浏览器无需刷新整个页面就能获取新数据,带来了更流畅的用户体验,催生了单页应用。

以亚马逊为例,它最初是一个庞大的单体应用。后来,它被重构为服务导向架构,将系统拆分为独立的服务(如用户服务、订单服务、商品目录服务)。每个服务:

  • 通过定义良好的API进行通信。
  • 可以独立开发、部署和扩展。
  • 允许使用最适合的工具(如不同编程语言)。

这种架构的优点包括:可重用性、灵活性、易于测试和团队分工。缺点则包括:系统整体测试更复杂、网络调用可能失败、部署多个服务运维更复杂。

对于本课程,你们的Rails应用大多是单体应用,但会集成许多第三方服务(如邮件服务),这同样能享受到服务化的优势。

API简介与实践

上一节我们讨论了服务间的通信,本节中我们具体看看它们通信的契约——API。

API(应用程序编程接口) 是两个系统之间的一份文档化契约。它规定了:

  1. 如何调用:使用什么HTTP方法和路由。
  2. 需要传递什么:必要的参数,如API密钥、查询词。
  3. 返回什么:返回数据的格式(如JSON)。
  4. 错误处理:出错时返回什么信息。

我们将以 The Movie Database (TMDB) 的API为例。其文档清晰,例如搜索电影的路由:

GET https://api.themoviedb.org/3/search/movie?api_key=YOUR_KEY&query=Coco

使用cURL调用:

curl 'https://api.themoviedb.org/3/search/movie?api_key=YOUR_KEY&query=Coco'

你会得到一个JSON格式的响应。JSON(JavaScript对象表示法)是一种轻量级数据交换格式,使用键值对和数组,易于解析。Ruby内置了对JSON的支持。

你也可以直接将API请求URL粘贴到浏览器地址栏中查看结果。

总结与课程安排

本节课中我们一起学习了Web的基础知识。我们了解了客户端-服务器模型、TCP/IP和HTTP协议如何协同工作。我们使用cURL工具实际发送了HTTP请求并检查了响应。我们还探讨了服务导向架构的理念,并介绍了API作为服务间通信契约的基本概念。

课程安排提醒:

  • Homework 1已发布,旨在引导你熟悉Ruby语法。
  • 即将进行团队组建调查。
  • Homework 2开始将引入同伴互评。
  • 下周将有随堂小测验。
  • 退课截止日期是本周五。

希望本节课的内容对你有所帮助。下节课我们将深入Rails框架的内部世界。

004:Ruby基础与REST API入门 🚀

在本节课中,我们将要学习Ruby编程语言的一些核心概念,并初步了解RESTful API的设计原则。我们将从Ruby的独特语法和面向对象特性开始,然后过渡到如何设计和使用Web API。


Ruby语言基础 🛠️

上一节我们介绍了课程安排和课堂工具,本节中我们来看看Ruby语言的一些基本特性和设计哲学。

一切都是方法调用

在Ruby中,一个核心概念是:一切皆是方法调用。当你看到 a.b 这样的表达式时,b 是对象 a 的一个方法,而不是一个属性或字段。

# 例如,数字相加
1 + 2
# 实际上等价于:
1.+(2)

这意味着Ruby的操作符(如 +, -, [])实际上都是定义在对象上的方法,这赋予了语言极大的灵活性和一致性。

类与实例变量

Ruby是面向对象的语言。以下是定义一个简单类的方法:

class SavingsAccount
  def initialize(starting_balance)
    @balance = starting_balance
  end

  def balance
    @balance
  end

  def balance=(new_amount)
    @balance = new_amount
  end
end
  • initialize 是构造方法。
  • @balance 是实例变量(以 @ 开头)。
  • balancebalance= 分别是获取和设置 @balance 值的方法。Ruby允许 balance = 100 这样的语法来调用 balance= 方法。

为了简化这种常见的“获取器”和“设置器”模式,Ruby提供了 attr_accessor 方法。

class SavingsAccount
  attr_accessor :balance

  def initialize(starting_balance)
    @balance = starting_balance
  end
end

attr_accessor :balance 这一行会自动为我们创建 balancebalance= 两个方法。

真值与假值

在条件判断中,Ruby的规则非常简单:

  • 只有 nilfalse 被视为
  • 其他所有值,包括 0 和空字符串 "",都被视为

if 0
  puts “0 is truthy!” # 这行会被执行
end

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs169-swe/img/188385e4e33c07762ebb26c392632fa9_37.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs169-swe/img/188385e4e33c07762ebb26c392632fa9_39.png)

if nil
  puts “This will not print.”
end

方法的返回值

Ruby方法会隐式返回最后一条表达式的值。return 关键字是可选的。

def greet
  “Hello” # 该方法返回字符串 “Hello”
end

def silent_method
  # 该方法没有最后一行表达式,返回 nil
end

互动问答与理解 🤔

以下是关于Ruby实例变量访问的一个思考题,帮助我们巩固理解。

问题:给定以下类定义,哪些方式可以合法地访问 @student_name 实例变量的值?

class Student
  attr_accessor :name
  def initialize(name)
    @student_name = name.capitalize
  end
end

选项:
A. my_student.@student_name
B. my_student.name
C. my_student.name()
D. my_student.student_name

解析

  • A 非法。在Ruby中,不能直接通过 对象.@变量名 的语法访问实例变量。
  • B 和 C 合法。attr_accessor :name 创建了 name 方法,用于获取 @name 实例变量的值。括号 () 在Ruby中是可选的。
  • D 非法。代码中并未定义 student_name 这个方法。实例变量 @student_name 和通过 attr_accessor 创建的 name 方法访问的 @name两个不同的变量

这个例子强调了Ruby通过方法访问数据的“统一访问原则”,以及 attr_accessor 的工作方式。


从Ruby到Web API 🌐

理解了Ruby的基本操作后,我们来看看如何用它来构建和与Web服务交互。现代Web开发的核心之一就是RESTful API。

什么是REST?

REST(Representational State Transfer)是一种设计Web API的架构风格。它强调以下三点:

  1. 资源:API操作的核心对象(如/books, /users)。
  2. 操作:通过HTTP方法(GET, POST, PUT/PATCH, DELETE)定义要对资源执行的动作。
  3. 数据:请求和响应中需要携带的额外信息(如参数、请求体)。

CRUD操作与HTTP方法

大多数对资源的操作可以归结为经典的CRUD模式,它们与HTTP方法有典型的对应关系:

操作 HTTP方法 典型用途 Rails控制器方法
Create (创建) POST 创建新资源 create
Read (读取) GET 获取单个资源 show
Update (更新) PUT/PATCH 更新现有资源 update
Delete (删除) DELETE 删除资源 destroy
列表/索引 GET 获取资源集合 index

例如,一个图书管理API可能包含以下端点:

  • GET /books - 获取所有图书列表(索引)。
  • GET /books/123 - 获取ID为123的图书详情(读取)。
  • POST /books - 创建一本新图书(创建)。
  • PUT /books/123 - 更新ID为123的图书信息(更新)。
  • DELETE /books/123 - 删除ID为123的图书(删除)。

理解API文档

阅读API文档时,需要关注:

  • 端点URL:例如 /books/{book_id}
  • HTTP方法:例如 GET
  • 参数:哪些是必需的(如 book_id),哪些是可选的(如 format=json)。
  • 响应格式:通常是JSON,也可能是XML或HTML。

关键点:仅凭一个URL示例无法判断参数的必填性。必须查阅官方文档或实际测试。


总结 📚

本节课中我们一起学习了:

  1. Ruby的核心特性:包括“一切皆是方法调用”的原则、类的定义、实例变量与 attr_accessor、真值假值规则以及方法返回值。
  2. RESTful API设计基础:理解了资源、HTTP方法和CRUD操作之间的对应关系,这是构建和消费现代Web服务的基础。

这些知识是后续学习Sinatra(一个简单的Ruby Web框架)和Rails(一个全功能的Ruby Web框架)的重要基石。请务必通过课后作业和实践来巩固理解。

005:REST API、HTML/CSS 与调试入门

在本节课中,我们将要学习 REST API 的核心概念、HTML 与 CSS 的基础知识,以及软件开发中至关重要的调试技巧。课程将以一个简单的 Sinatra 框架演示作为结尾。

微测验与课程回顾

今天课程的前 10 到 15 分钟将进行一个微测验。测验包含三道选择题,预计耗时 5 到 10 分钟。你可以直接访问 GradeScope,测验名为 microquiz1,也可以通过 B Courses 上的链接进入。

对于每个问题,请仔细阅读题目和选项,然后选择正确答案。微测验的目标是检验学习成果,但总体而言,我们更看重参与度而非绝对的正确率。只要在本学期中答对大约一半的微测验题目,你就能获得该部分的满分。本学期将会有大约六次这样的测验。

你可以在 GradeScope 上提交答案,每答完一题后可以重新提交。系统不会显示正确答案。请预留大约 10 分钟来完成这个测验。


REST API 深入探讨

上一节我们介绍了 REST 的基础。本节中,我们来看看 HTTP 方法如何与 CRUD 操作对应,以及如何设计良好的 API 端点。

HTTP 方法与 CRUD

  • GET 对应 读取(Read)。当你只想获取数据时使用。
  • POST 通常用于 创建(Create) 新资源。例如,在电影数据库中创建一部新电影。POST 请求通常包含一个 请求体(Body),用于传递创建资源所需的数据。
    • 示例POST /movies,请求体包含 {“title”: “Inception”, “rating”: “PG-13”}
  • PUTPATCH 用于 更新(Update) 资源。两者用法非常相似,HTTP 规范对它们有细微的区分,但在本课程中可视为等同。更新时通常只需发送需要更改的部分信息。
    • 示例PATCH /movies/123,请求体包含 {“release_date”: “2023-07-21”}。这里的 123 是用于标识特定电影的 ID。
  • DELETE 用于 删除(Delete) 资源。

非标准 CRUD 操作与 API 设计

有时操作可能不严格符合单一的 CRUD 模型。例如,将一部电影添加到“我的观看列表”中。这并非创建一部新电影,更像是更新一个观看列表。

设计良好的 RESTful API 的关键在于思考“主要的资源是什么”。如果一个操作涉及两个不同的数据实体,这通常暗示你可能需要创建一个新的资源来代表这两者的组合。这个概念在后期的课程中会以 连接表(Join Table) 的形式再次出现。

例如,“课程注册”可以是一个独立的资源(/course_enrollments),通过 POST 请求来创建,它关联了“学生”和“课程”两个实体。这样,每个实体(资源)都有一套清晰的基本操作(CRUD),使 API 易于理解和使用。

设计原则总结

以下是 RESTful API 设计的核心要点:

  • 一切都是资源:电影、课程、学生等。
  • 使用标准操作:创建、读取、更新、删除、列表(索引)。
  • 明确副作用:API 调用应清晰表明其意图和可能产生的状态变化。
  • 使用 JSON:作为数据交换格式,JSON 易于机器解析和人工阅读,是 API 设计的绝佳起点。
  • 保持简单直观:良好的 RESTful 设计能简化复杂应用的理解和交互。


HTML 与 CSS 基础

HTML 和 CSS 构成了 Web 的呈现层。浏览器接收 HTML 文档,并根据 CSS 样式将其展示出来。

HTML 简介

HTML 是一种标记语言,用于描述网页的内容和结构。

  • 标签(Tags):用尖括号定义,如 <h1><p><div>
  • 属性(Attributes):提供关于标签的额外信息,如 <img src=”image.jpg”> 中的 src,或 <a href=”https://example.com”> 中的 href
  • 内容(Content):标签之间的文本或其他标签。

示例 HTML 结构

<div class=”container”>
  <h1>这是一个标题</h1>
  <p>这是一个段落,包含一个 <a href=”#”>链接</a>。</p>
  <ul>
    <li>列表项 1</li>
    <li>列表项 2</li>
  </ul>
</div>

CSS 简介

CSS 代表层叠样式表,用于设置 HTML 元素的样式。

  • 选择器(Selectors):用于定位要样式化的 HTML 元素。
    • 元素选择器p { color: blue; }
    • 类选择器(Class):使用点号 .,如 .row { … },对应 HTML 中的 class=”row”
    • ID 选择器:使用井号 #,如 #header { … },对应 HTML 中的 id=”header”
    • 后代选择器div p { … } 选择所有在 <div> 内的 <p> 元素。

使用 Bootstrap

Bootstrap 是一个流行的前端 CSS 框架,它提供了一套预定义的样式和组件,能快速构建美观、响应式(适配不同屏幕尺寸)的网站。对于本课程,我们推荐使用 Bootstrap 作为起点,因为它能处理大量复杂的样式细节,让你更专注于应用逻辑。


调试与寻求帮助

调试是编程工作中不可避免的一部分。随着项目复杂度增加(引入框架、库等),调试技巧显得尤为重要。

通用调试步骤

  1. 明确预期:你期望代码产生什么结果?
  2. 观察实际结果:实际得到了什么?是错误信息、错误输出还是无输出?
  3. 提出假设:根据差异,猜测可能的原因。
  4. 测试假设:通过修改代码、重启服务、添加日志等方式验证你的猜测。
  5. 寻求他人帮助:与同伴讨论,一双新的眼睛常常能迅速发现你忽略的简单错误,如拼写错误或配置问题。

处理错误信息

  • 仔细阅读:错误信息通常包含问题的线索,即使它看起来晦涩难懂。
  • 识别来源:错误信息中提到的库名(如 ActiveRecord)、文件名和行号能帮你定位问题根源。
  • 利用搜索引擎:将错误信息复制到 Google 搜索。如果错误信息中包含你本地的文件路径,可以将其删除后再搜索,以获得更通用的结果。
  • 提供最小可复现示例:在 Piazza 或论坛提问时,提供能重现问题的最简代码和环境信息,并说明你已经尝试过哪些方法。

常见错误与工具

  • undefined method for nil:NilClass:这是一个非常常见的错误,意味着你尝试在一个 nil 值上调用方法。你需要找出为什么这个变量是 nil
  • 使用调试器:如 byebugdebugger 等工具,可以让你在代码中设置断点,逐步执行并检查变量状态。
  • 打印调试:使用 putsp 语句输出变量值,是简单有效的调试方法。
  • 浏览器开发者工具:用于检查网络请求、HTML 结构、CSS 样式和 JavaScript 控制台错误,是 Web 调试的利器。

Sinatra 框架快速演示

最后,我们通过一个极简的 Ruby Web 框架——Sinatra,来直观感受一下 Web 应用如何处理请求和响应。

一个基本的 Sinatra 应用可能如下所示(app.rb):

require ‘sinatra’

class DemoApp < Sinatra::Base
  get ‘/’ do
    “It was #{params[:something]}.”
  end
end

  • require ‘sinatra’:引入 Sinatra 库。
  • get ‘/’ do … end:定义了一个路由,当用户通过 GET 方法访问根路径 / 时,执行块内的代码。
  • params[:something]:获取 URL 查询参数。例如,访问 http://localhost:9292/?something=awesome,页面将显示 “It was awesome.”。

使用 rackup 命令可以启动这个应用服务器。默认情况下,它会在 http://localhost:9292 上运行。这个简单的例子展示了 Web 框架如何将特定的 URL 路径映射到相应的处理代码,并生成返回给浏览器的内容。


总结

本节课中我们一起学习了:

  1. REST API 设计:HTTP 方法与 CRUD 操作的对应关系,以及如何为复杂操作设计合理的 API 端点。
  2. Web 基础:HTML 用于定义内容结构,CSS 用于控制样式表现,并介绍了 Bootstrap 框架。
  3. 调试策略:系统化的调试步骤、错误信息分析技巧以及有效的求助方法。
  4. 框架初探:通过 Sinatra 的微型演示,直观了解了 Web 应用路由的基本工作原理。

请记住,掌握这些工具和概念的最佳方式是在实践项目中不断使用和探索。

006:Sinatra应用开发与依赖管理 🚀

在本节课中,我们将继续学习Sinatra Web应用框架。我们将完成对基本请求、视图和参数处理的介绍,然后探讨如何使用Cookie共享会话信息。最后,我们将了解Ruby中的依赖管理,并初步接触模型-视图-控制器(MVC)的概念。

课程提醒与回顾 📝

首先是一些课程提醒。作业2将于本周五截止,采用问答形式,具体要求已发布在B课程平台。作业提交截止后,下周将进行同伴互评,这也会作为一个小作业发布在B课程平台。互评任务会在所有提交完成后通过Piazza通知大家。互评不会非常困难,会有评分标准,大家需要根据标准评估同伴的作业。由于作业2涉及真实的API调用,答案不唯一,因此采用同伴互评而非自动评分。此外,本周四课堂上将有一个小测验,包含两三个简短问题,形式与之前相同,在GradeScope上进行。

上一节我们介绍了如何在Sinatra中创建简单的“Hello World”应用。我们位于应用栈的应用服务器层,它负责处理请求和响应。我们的任务是控制应用在收到请求时如何响应。在课程的前半部分,我们将处理无状态的请求,即每个请求完全独立,不共享信息。之后,我们将探讨如何处理状态,即如何保存和重新加载信息。

深入Sinatra:路由、参数与视图 🛣️

现在,让我们深入Sinatra应用。我已经使用 rerun rackup 命令启动了服务器。rerun 是一个方便的gem,可以在文件更改时自动重新加载应用,这也是Sinatra文档推荐的方式。

服务器启动后,终端会显示日志。每一行代表一个请求的结果,例如 GET /,并包含HTTP状态码(如200表示成功)。访问服务器,反复刷新会加载一个非常简单的网页。

处理路由与查询参数

我们的应用目前处理两个路由:get '/'get '/i_love/:something'

对于根路由 '/',我们定义了一个名为 something 的参数,可以通过查询字符串传递。例如,访问 /?something=good,页面会显示“It was good”。这里的URL包含一个参数,Sinatra会将其传递给视图。目前,我们的视图只是一个简单的字符串,甚至不是完整的HTML。

第二个路由是 get '/i_love/:something'。这里的 :something 是一个通配符,意味着URL中该位置的内容可以任意变化。例如,访问 /i_love/dogs,页面会显示“I love dogs”。

嵌入Ruby的视图(ERB)

那么,“I love dogs”这个页面是如何生成的呢?关键在于这一行代码:erb :helloerb 代表 Embedded Ruby,它允许我们在HTML文件中混合Ruby代码。

在我们的应用结构中,有一个名为 views 的文件夹,里面有一个 hello.erb 文件。这个文件看起来像HTML,但包含特殊的标签:<%= @something %>。在HTML中,没有以百分号开头的标签。这里的 <%= ... %> 标签之间的内容会被当作Ruby代码执行。

具体来说,Sinatra框架知道符号 :hello 对应 views/hello.erb 这个文件。它会读取该文件,执行其中的Ruby代码(例如访问变量 @something),然后将生成的HTML发送回浏览器。这是一种在HTML中传递数据的常见方式。

在更复杂的应用中,你可能会看到类似 <%= @user.first_name %> 的代码。但一般来说,应尽量保持视图中的逻辑简单。

参数传递与作用域

在路由定义 get '/i_love/:something' do 中,通配符 :something 会被Sinatra放入一个名为 params 的哈希中,键为 :something。我们可以在路由处理代码中通过 params[:something] 访问它,并将其赋值给实例变量 @something,以便在视图中使用。

实例变量 @something 可以在当前方法(路由处理块)中使用,也可以在其对应的视图中使用。Sinatra(以及Rails)在幕后做了一些工作,将控制器中的实例变量传递给了视图类,这是一种为了方便程序员的约定。

URL中的查询参数(问号后的部分)也会被放入 params 哈希。例如,访问 /i_love/coffee?food=toast&class=cs169params 哈希将包含 :something:food:class 三个键。

可选参数与默认值

默认情况下,路由中的通配符是必需的。如果我们希望某个参数是可选的,可以使用正则表达式风格的语法。例如,将路由改为 get '/i_love/(:something?)' do,其中的问号表示 :something 是可选的。

在Sinatra中,没有直接在路由定义中设置参数默认值的标准语法。我们通常的做法是:在路由处理代码中,使用Ruby的 || 操作符来为 params 哈希中可能为 nil 的值设置默认值。

使用Cookie管理会话状态 🍪

到目前为止,我们的应用是无状态的。每个请求独立,无法在不同请求间共享信息(比如用户登录状态)。接下来,我们看看如何使用Cookie来实现会话管理。

Sinatra通过启用 sessions 来支持Cookie。我们在新的示例应用中可以看到 enable :sessions 这行代码。

会话的工作原理

会话本质上是一个哈希,用于在多次请求和页面浏览间持久化数据。它的工作原理是:

  1. 服务器在响应中设置一个Cookie。
  2. 浏览器保存这个Cookie。
  3. 浏览器在后续对同一站点的请求中自动携带这个Cookie。
  4. 服务器读取Cookie,还原出会话哈希。

在我们的示例中,访问 /set/:someone 路由(例如 /set/Professor%20Fox)会执行以下操作:

  1. params[:someone] 的值(如“Professor Fox”)存入 session[:someone]
  2. 使用 redirect '/' 将浏览器重定向到根路径。
  3. 在根路径对应的视图中,我们读取 session[:someone] 并显示。

这样,我们就实现了跨请求的数据共享。

会话的作用域与限制

  • 作用域:会话是基于浏览器域名的。同一浏览器的不同标签页通常共享会话。不同浏览器(如Chrome和Safari)或同一浏览器的隐私浏览模式则拥有独立的会话。
  • 持久性:会话Cookie通常被设置为“浏览器会话期间有效”,关闭浏览器后可能失效。但浏览器行为可能不同,有些会长期保存。服务器也可以设置具体的过期时间。
  • 大小限制:Cookie有大小限制(通常为4KB)。如果尝试在会话中存储超过此限制的数据,Sinatra可能无法正确处理,Rails可能会抛出错误。这是为了防止网站滥用Cookie存储过多用户数据。

Ruby依赖管理(Gem与Bundler)📦

在构建软件时,我们很少从零开始。Ruby拥有丰富的生态系统(Gem),我们需要工具来管理这些依赖库及其版本,确保开发、测试和生产环境的一致性。

Gemfile与Gemfile.lock

Ruby使用 Bundler 进行依赖管理。每个项目通常包含两个关键文件:

  • Gemfile:声明项目所需的Gem及其版本范围。它是一个宽松的、描述性的文件。
    # Gemfile 示例
    ruby '>= 2.3', '< 3.0'
    gem 'sinatra', '>= 2.0'
    gem 'sinatra-flash'
    
  • Gemfile.lock:由Bundler自动生成,记录当前安装的Gem的精确版本及其所有依赖关系的快照。它确保了环境的一致性。

语义化版本(SemVer)

为了理解版本号变化的含义,社区广泛采用 语义化版本控制(SemVer) 规范。版本号格式为 主版本号.次版本号.修订号(例如 2.1.5)。

  • 修订号 递增:表示向后兼容的问题修复。更新通常是安全的。
  • 次版本号 递增:表示向后兼容的功能性新增。可能包含弃用警告,但不应破坏现有功能。
  • 主版本号 递增:表示可能包含不向后兼容的更改。升级时需要谨慎测试和修改代码。

遵循SemVer可以帮助开发者评估升级依赖的风险。

版本控制策略

那么,GemfileGemfile.lock 文件应该提交到Git仓库吗?

  • 必须提交 Gemfile:因为它定义了项目依赖的蓝图。没有它,其他开发者无法知道需要安装哪些Gem。
  • 必须提交 Gemfile.lock:因为它锁定了所有依赖的确切版本,确保了团队所有成员以及生产环境使用完全一致的依赖树,避免了“在我机器上能运行”的问题。当 Gemfile 变更后,Bundler会更新 Gemfile.lock

因此,两个文件都需要提交到版本控制系统

总结 🎯

本节课我们一起学习了以下内容:

  1. Sinatra进阶:深入了解了路由定义、通配符参数、查询参数的处理,以及如何使用ERB视图嵌入Ruby代码来动态生成HTML。
  2. 状态管理:探讨了Web应用中的状态问题,并学习了如何通过启用Sessions和利用Cookie在Sinatra中实现跨请求的会话状态共享,包括其作用域和限制。
  3. 依赖管理:介绍了Ruby使用Bundler进行依赖管理的基本原理,理解了Gemfile(声明依赖)和Gemfile.lock(锁定精确版本)的作用与区别,并了解了语义化版本控制(SemVer)规范如何帮助我们管理依赖升级。
  4. 最佳实践:明确了应将GemfileGemfile.lock一同提交至版本控制系统,以保证开发环境的一致性。

这些知识为我们接下来学习更复杂的Rails框架和构建实际项目打下了坚实的基础。

007:MVC 与 Rails 入门 🚀

在本节课中,我们将学习模型-视图-控制器(MVC)设计模式,并了解 Ruby on Rails 框架如何实现这一模式,以帮助我们更高效地构建结构化的 Web 应用程序。

概述

上一节我们介绍了 Sinatra 框架的基础知识。本节中,我们将探讨 MVC 设计模式的核心概念,并了解 Ruby on Rails 框架如何利用这一模式来组织应用程序的代码结构。

什么是 MVC?

MVC 是一种将应用程序逻辑分为三个核心组件的软件设计模式:

  • 模型 (Model):负责数据和业务逻辑。
  • 视图 (View):负责用户界面和展示。
  • 控制器 (Controller):作为模型和视图之间的中介,处理用户输入并协调响应。

在 Sinatra 中,我们主要接触了控制器和视图。Rails 框架则完整地采用了 MVC 模式,并提供了强大的工具(如 Active Record)来简化模型层与数据库的交互。

MVC 并非 Web 应用专属

需要明确的是,MVC 是一种通用的设计模式,并非仅用于构建 Web 应用。例如,iOS 或 macOS 的客户端应用程序也广泛使用 MVC 来组织代码。对于 SaaS 应用而言,MVC 是一个非常合适的范式。

以下是关于 MVC 模式的一些常见理解,其中一项并不总是成立:

  • A. 所有 MVC 应用都使用 HTTP 协议。
  • B. MVC 应用必须包含客户端、Web 服务器和云端组件。
  • C. MVC 只是构建 SaaS 应用的几种方式之一。
  • D. MVC 可用于点对点(P2P)应用。
  • E. 在 MVC 中,控制器将实例变量传递给视图。

正确答案是 B。MVC 是一种设计模式,它不要求应用程序必须部署在云端或具有特定的网络架构。一个本地的桌面应用同样可以采用 MVC 模式。

Rails 中的 MVC

Rails 是一个遵循“约定优于配置”原则的 MVC 框架。这意味着,只要你遵循其命名和组织文件的约定,就能极大地减少配置工作,快速构建功能。

在 Rails 应用中,代码结构非常清晰:

  • 模型文件存放在 app/models/ 目录。
  • 视图文件存放在 app/views/ 目录。
  • 控制器文件存放在 app/controllers/ 目录。

这种一致性使得开发者能够轻松地在不同的 Rails 项目间切换和理解代码。

请求处理流程

让我们通过一个例子来看 Rails 中 MVC 如何协同工作。假设我们访问 /movies/3 这个 URL 来查看 ID 为 3 的电影。

  1. 路由config/routes.rb 文件中的配置会将这个 URL 映射到 movies 控制器的 show 动作。
    # config/routes.rb
    get ‘movies/:id‘, to: ‘movies#show‘
    
  2. 控制器MoviesController 中的 show 方法会被调用。它通过模型查找数据,并存储在实例变量中,准备传递给视图。
    # app/controllers/movies_controller.rb
    def show
      @movie = Movie.find(params[:id])
      # Rails 默认会自动渲染对应的视图
    end
    
  3. 模型Movie 模型(通常继承自 ActiveRecord::Base)与名为 movies 的数据库表交互,执行 find 操作。
  4. 视图:Rails 遵循约定,会自动在 app/views/movies/ 目录下寻找名为 show.html.erb 的视图文件来渲染。控制器中的实例变量 @movie 在视图中可以直接使用。
    <%# app/views/movies/show.html.erb %>
    <h1><%= @movie.title %></h1>
    <p>Director: <%= @movie.director %></p>
    

Rails 的智能之处在于,它能根据类名自动推断数据库表名(例如,模型 Movie 对应表 movies),这得益于 Ruby 强大的自省能力。

# Ruby 自省示例
book = Book.new
book.class # => Book
book.class.name # => “Book”
book.class.name.downcase.pluralize # => “books“ (Rails 会进行更智能的复数化)

控制器必须渲染响应

在 Rails 中,每个控制器动作最终都必须向客户端返回一个响应。这是由 HTTP 协议的本质决定的——它是一种请求-应答协议。

以下是控制器可以渲染的几种响应类型:

  • 渲染一个 HTML 视图(最常见)。
  • 返回 JSON 数据(用于 API)。
  • 返回纯文本。
  • 重定向到另一个 URL(本质上也是返回一个带有重定向指令的 HTTP 响应)。
  • 返回特定的 HTTP 状态码(如 204 No Content)。

即使响应没有直接的“可视化”输出(例如一个文件下载或一个 API 调用),服务器“渲染”一个响应的过程也是必不可少的,以确保客户端不会无限期等待。

总结

本节课我们一起学习了 MVC 设计模式的核心思想,并初步探索了 Ruby on Rails 框架如何实现这一模式。我们了解到:

  1. MVC 将应用分为模型、视图和控制器,有助于分离关注点,使代码更易于维护。
  2. Rails 通过严格的约定和目录结构,简化了 MVC 应用的构建。
  3. 控制器的核心职责是处理请求,与模型交互,并最终渲染一个响应(视图、重定向、JSON等)返回给客户端。

从下节课开始,我们将深入 Rails 的各个组成部分,特别是强大的模型层工具 Active Record。

008:Active Record、调试与表单

在本节课中,我们将要学习Rails框架中的核心组件Active Record,它负责连接Ruby对象与数据库。我们还将探讨如何调试Rails应用,以及如何处理用户通过表单提交的数据。课程内容旨在让初学者能够理解这些概念并应用到实际开发中。

Active Record与数据库交互

上一节我们介绍了MVC架构,本节中我们来看看模型(Model)层如何与数据库进行交互。Active Record是Rails中用于对象关系映射(ORM)的模式,它充当了内存中的Ruby对象和数据库表中持久化数据之间的桥梁。

核心概念:对象与存储

在程序中操作的是内存中的对象,而数据库则以表格形式存储数据。我们需要一种方式在两者之间进行转换。Active Record通过将每个模型类映射到一个数据库表来实现这一点。表的每一行对应一个对象实例,每一列对应对象的一个属性。

公式/代码示例:

class Movie < ActiveRecord::Base
end

上述代码意味着Movie模型继承自ActiveRecord::Base,Rails会自动将其关联到数据库中的movies表。

基本操作:CRUD

Active Record抽象了SQL查询,允许我们使用Ruby方法进行创建(Create)、读取(Read)、更新(Update)和删除(Delete)操作。

以下是CRUD操作的基本方法:

  • 创建:使用new方法在内存中创建对象,然后调用save方法保存到数据库。create方法将newsave合并为一步。
  • 读取:使用where方法进行条件查询,或使用find方法通过主键ID查找特定记录。
  • 更新:获取对象后,修改其属性并调用save方法,或使用update_attributes方法一次性更新并保存。
  • 删除:对对象调用destroy方法。

重要提示find方法在找不到记录时会抛出异常,而where方法查询无结果时返回空集合,使用时需注意处理。

数据库迁移(Migrations)

在开发过程中,我们经常需要修改数据库结构(如添加列、修改表)。直接操作生产数据库是危险的。Rails的迁移功能提供了一种安全、可追踪、可回滚的方式来管理数据库模式(Schema)的变化。

迁移脚本是Ruby文件,描述了要对数据库进行的更改。通过运行rails db:migrate命令,Rails会按顺序执行这些脚本,并自动更新schema.rb文件来反映当前数据库结构。

核心优势

  • 自动化与可重复性:避免手动执行SQL命令出错。
  • 环境隔离:可在开发、测试环境中先行测试迁移,再应用到生产环境。
  • 版本控制:迁移文件可纳入版本控制,便于团队协作和回滚。

控制器、视图与表单

上一节我们了解了数据如何存储,本节中我们来看看用户如何通过界面与数据交互。这涉及控制器(Controller)处理请求,视图(View)呈现页面,以及表单(Form)收集用户输入。

添加新动作(Action)

在Rails应用中添加一个新功能(例如“创建电影”),通常需要以下步骤:

  1. 配置路由:在config/routes.rb文件中定义访问该功能的URL路径和HTTP动词。
  2. 创建控制器动作:在相应的控制器中编写一个方法来处理该路由的请求。
  3. 创建视图(可选):如果该动作需要向用户展示页面,则需创建对应的视图模板文件(如.html.erb)。

并非所有动作都需要渲染视图,例如处理表单提交的create动作通常执行完操作后重定向到其他页面。

表单处理

表单是用户提交数据的主要方式。Rails提供了丰富的表单辅助方法(Form Helpers)来简化表单的创建和数据绑定。

表单处理流程

  1. 显示表单:通过一个GET请求的动作(如new)渲染包含表单的视图。
  2. 提交表单:用户填写表单后,数据通过POST请求发送到另一个动作(如create)。
  3. 处理数据:在create动作中,通过params哈希获取表单数据,使用Active Record创建或更新模型,然后保存到数据库。

代码示例(表单辅助方法)

<%= form_tag movies_path do %>
  <!-- 表单字段 -->
<% end %>

或使用更面向资源的form_for(在更新版本中为form_with):

<%= form_for @movie do |f| %>
  <%= f.text_field :title %>
<% end %>

调试(Debugging)技巧

开发过程中难免遇到错误。Rails应用层次多,调试的关键在于定位问题所在。

以下是几种有效的调试方法:

  • 使用putslogger:在代码中插入输出语句,打印变量值或执行路径。
  • Rails控制台:使用rails console命令在应用上下文中交互式地执行代码和查询数据。
  • Byebug调试器:在代码中插入byebug语句,运行时会在此处进入交互式调试会话,可以逐行执行、查看变量。
  • 视图调试:在视图中使用<%= debug(@object) %><%= @object.inspect %>来显示对象内容。

良好实践

  • 增量修改:每次做小的改动并测试,确保功能正常。
  • 理解代码:避免盲目复制粘贴,确保理解所集成代码的上下文和作用。
  • 查看日志:检查log/development.log文件,其中记录了应用的详细运行信息。

Flash消息

在处理表单提交等操作后,我们经常需要重定向用户到另一个页面,并显示一条一次性提示信息(如“操作成功!”)。由于HTTP是无状态的,重定向后新的请求无法直接获取之前请求中的变量。Rails的flash机制专门用于解决这个问题。

flash是一个特殊的哈希,其中存储的消息会在下一次请求中可用,随后自动清除。这使其非常适合用于重定向后的成功或错误提示。

代码示例

# 在控制器动作中设置flash
def create
  @movie = Movie.new(movie_params)
  if @movie.save
    flash[:notice] = ‘电影创建成功!’
    redirect_to movies_path
  else
    render ‘new’
  end
end
<%# 在布局文件(如application.html.erb)中显示flash %>
<% if flash[:notice] %>
  <div class=“notice”><%= flash[:notice] %></div>
<% end %>

总结

本节课中我们一起学习了Rails开发中的几个核心主题。我们深入了解了Active Record如何作为ORM工具,优雅地连接Ruby对象与数据库,并执行CRUD操作。我们认识了使用迁移来安全地管理数据库结构变更的重要性。接着,我们梳理了通过控制器、视图和表单来实现用户交互的完整流程。最后,我们掌握了一些实用的调试技巧,并学会了使用flash在请求间传递一次性消息。理解这些概念是构建健壮Rails应用的基础。

009:行为驱动设计与用户故事 📝

在本节课中,我们将要学习如何通过行为驱动设计(BDD)和用户故事来规划和管理软件开发项目。我们将了解如何与客户协作,将模糊的需求转化为具体、可测试的开发任务,并学习如何使用低保真原型来验证设计思路,从而避免在错误的方向上投入过多精力。

课程公告与资源

课程开始有一些事务性通知。

这里有一个GitHub仓库的链接,它被列为一项作业。但它不是一项指定的作业,而是类似于之前作业的ActiveRecord练习。它包含了测试用例,并以作业的风格设置,但不会被评分。这只是一个可用的资源,作为ActiveRecord入门的有效练习。

到目前为止,你在课堂上已经接触过一点ActiveRecord。在开发Rails应用时,ActiveRecord要么是你的朋友,要么是你的敌人。你肯定希望它是朋友,因为它是Rails中非常关键的一部分。人们常说,正是这些东西让Rails成为Rails。它是你与数据库交互、获取和存储构成应用独特性的对象和数据的接口。

请查看这个仓库进行练习。这对期中考试也是很好的练习。

期中考试是下一件大事,定于下周二。本周会发布一些样题。期中考试的形式是两小时的纸质考试,考场信息会在Piazza上公布,初步定在105 GPB。这基本上是一次标准的CS期中考试:包含选择题部分、简答题部分(例如填空、补全代码行),以及一些更开放的问题。肯定会有一道关于Sinatra的题目,比如“补全这个Sinatra应用的方法”。还会有一些观点性问题,例如“这个说法是否有意义?请证明你的答案。” 你可以带一张双面笔记,但不能使用过小的字体,也不能带放大镜。如果需要,对于ActiveRecord等问题,我们会提供必要的背景信息或方法说明,不会要求你死记硬背Ruby语法细节。

我们还会发布一个Google表单,让大家提交选择题和简答题的建议。这个想法是,当你在复习时,如果想到一个关于课堂内容、作业或讨论部分的公平或有趣的评估问题,可以提交上来。表单可能在周五左右关闭,然后我们会发布学生提交的问题集。我们会挑选一部分,并调整和澄清措辞,使其对所有学生都有意义。提交问题不保证会被采用,但有机会出现在期中考试中。如果你的问题被选中,你将获得一个额外加分,因为你为学习和帮助同学复习做出了贡献。

在资源列表上,我们会在Piazza上发布一个链接。这是一个由历年CS 169学生构建的应用。链接是 questionbank.saasbook.info。你用GitHub登录后,会有一些课程助教设置的额外私有题目,但公开部分已经有超过100道选择题可供学习。我们不会直接选用公开题目,但你可以搜索这些题目。它们目前组织得不是特别有条理,但这将是一个可用的学习资源。除了样卷,我们也会提供这个。由于这是CS 169的内容,其中大约50%的题目可能涉及我们尚未讲到的内容(因为这是期中考试1),但作为学习材料是可用的。

项目与团队协作 🚀

本课程的一个重要组成部分是项目。我们将在本周晚些时候提供所有项目选项的列表。根据时间安排,你们团队将对这些项目进行投票。从下周或下下周开始,你们将正式开始与客户会面。选择项目后,我们会提供客户联系方式,你们团队需要主动联系客户、安排会议。我们会提供一些关于会议如何进行等的框架。

项目将很快启动。作为其中一部分,我们将使用一个全班级范围的Slack。你们将在接下来的一周左右收到邀请。每个团队将有一个频道用于团队沟通。我们要求你们使用这个平台进行团队沟通。在这个Slack中,我们会设置一些自动化工具,用于检查进度和报告。这样做有两个目的:一是拥有一个集中的团队沟通场所非常有用;二是在“现实世界”中,Slack现在是这类工作的实际标准应用,其集成功能对构建软件至关重要。稍后我会在Piazza分享一个几年前的很棒的演讲,名为“Github如何用Github构建Github”,其中讲述了他们如何利用聊天和其他工具来构建现代软件。我们不会完全照做,因为我们的应用没有Github那么复杂,但这能提供一些关于团队协作和信息访问的思路。请留意相关信息。

行为驱动设计与用户故事介绍

讲完这些通知,今天开始的内容是关于如何开发你们的项目。今天没有现场演示,主要是一些开发实践,可以说是偏“软技能”的一面。其中一些内容可能看起来显而易见,但可以保证在实践中是困难的。

Armanda Fox有一些“计算机历史时刻”。这一个很有趣,它展示了软件的悠久历史。这是一张1950年代的支票,恰好是美洲银行的。支票上缺少什么?如果你没写过支票也很正常,因为现在很多人甚至不再写支票了。这张支票缺少几样东西。一个是什么?可以大声说出来。是的,缺少备注栏。这确实缺失了,但不是最重要的缺失项。路由号码?是的,路由号码,还有账号。在1950年代计算机化之前,没有银行分行的概念。你兑现支票时是去当地分行,或者邮寄到当地分行。当时美洲银行作为一家公司存在,但所有业务都在本地处理。这就是为什么有时别人问你要银行地址时,他们仍然想要一个具体的本地地址,这有点奇怪,因为现在填写表格时,我的银行地址可能在几百英里外。但在1950年代,这很重要。

美洲银行当时是一家大银行,他们提出了一个名为ERMA的系统(电子记录机器会计)。在50年代,他们聘请了SRI(斯坦福国际研究院)。如果你听说过Siri,这个名字实际上源于一个SRI项目,他们想以团队命名,所以加了一个“i”变成了一个名字。SRI与美洲银行合作提出了这个演示系统,并在接下来的大约10年里,美洲银行构建了原型并投入生产。仅仅几年后,美洲银行所有的支票账户会计都通过这个系统处理。考虑到在1960年代建立和维护计算机所需的巨大努力,这个时间相对较短。后来在60年代,它们被IBM 360取代,我们稍后会在另一个计算机历史时刻中谈到IBM 360,它本身也是计算技术的一个里程碑。

从ERMA系统中诞生的一项技术是MICR(磁墨水字符识别)。今天我们可以很好地对手写体进行OCR识别,邮局一直在使用。但他们当时想出的办法是:我们希望自动将支票路由到正确的地方。于是就有了路由号码。最初只用于美洲银行,但现在你可以在所有支票上看到。这个想法很有趣:当时没有光学字符识别的方法,但如果使用磁性墨水(墨水中有铁微粒),他们创造了一套数字,这些数字人类可读,但当磁头扫描时,每个数字都有自己独特的磁信号特征。你会注意到“1”有一个大墨点,但大体上仍能看出是“1”,“4”在某些地方很粗,“8”也是。他们创造了这种数字格式。50多年后的今天,它仍然存在于支票上。这些墨水仍然是磁性的,这意味着你不能随便在家打印支票。你去银行拿到支票簿或现金支票时,使用的是特殊墨水和专用打印机来正确编码。不过今天,随着移动存款的出现,我们已用更智能的计算机取代了磁性识别部分,但相同的东西依然存在。如果你感兴趣,有一篇很酷的《连线》文章讲述了这段历史。美洲银行在康科德有一个很大的次级总部,那里还保存着一台ERMA设备。当然,它不再运行了,但如果你在康科德地区,可以去看看。

这就是计算机历史时刻。同样,我们只是偶尔讲讲这些,不会考试,但仍然是关于计算创新长尾的有趣事情。

行为驱动设计与用户故事详解

今天的内容是行为驱动设计和用户故事。这里的核心思想是:我们可以使用哪些工具和流程来确保工作成功?当我们谈论敏捷时,我们实际上是在谈论利益相关者。对你们来说,就是你们的客户,他们将提供反馈和方向。你们应该期待与客户进行多次(如果可能的话)面对面会议,或者至少是视频会议,并在课程期间保持频繁的电子邮件沟通。这些客户也可能是你们公司内部应用的用户,可能是其他开发者,也可能是商业智能团队等。

我们希望以短迭代周期工作,快速交付反馈,展示产品进展,然后获得更多反馈。我们将进行为期两周的迭代,这对本课程的时间安排很重要。

如果你上过用户界面课程(如CS 160),或者参加过像Berkeley Innovation这样的社团,你可能听说过行为驱动设计或行为驱动开发。其思想是,在开发之前和期间,我们要询问我们的应用应该做什么。重点不在于说“这个用户模型是否正确返回了用户的注册日期”或“是否正确计算了用户登录次数”这类具体实现,而在于我们能否在高层面上决定应用应该做什么。行为驱动开发不一定能证明我们的应用做了正确的事(这很难),但它会尝试验证我们遵循了正确的流程。

我们将通过用户故事来实现这一点。用户故事会以两种形式体现:手写或打字的用户故事,以及我们将把它们转化为可运行的、看起来很像英语的测试用例。用户故事旨在轻量级。用户故事是对应用应该做什么、影响谁以及应用于应用的哪个部分的高层描述。这里的关键是,我们始终讨论的是行为,而不是实现。你可能听说过测试驱动开发,它与行为驱动设计密切相关,但行为驱动的重点在于实际的设计、用例和应用应该做什么,而不一定是它实际做了什么,更重要的是,绝对不是它如何工作。当我们编写测试用例时,它们将是高层次的,例如“我应该登录页面,然后应该看到我的用户名的确认信息”。BDD不会验证你的实现,也不会要求特定的实现,但它会帮助你组织实现。

这是我们反复出现的进度幻灯片。今天我们将讨论与客户沟通的内容,稍后在讲座中会讲到线框图。本周晚些时候,我们将重点讨论用户故事,并接触Cucumber,这将是DX作业的一部分。然后最终,我们可能在下周期中考试后,开始使用Pivotal Tracker和将应用部署到Heroku。我们将在接下来两周内完成整个流程。提醒一下,在这个过程中,我们会反复讨论遗留代码和设计模式等内容。

用户故事的结构与示例

这是一个非常简单的用户故事。这是一张3x5英寸的索引卡。如果你能弄到一叠索引卡,它们是完美的工具,我们稍后会解释原因。用户故事有一个标题:“添加电影”。内容如下:“作为一个电影迷,为了能与他人分享电影,我希望将电影添加到烂土豆数据库。” 用户故事很短,只有几句话,可以写在一张3x5卡片上。它们不仅是你的团队编写的,而且是与客户协作编写的。重点是,你将与客户一起明确他们希望发生什么。

这里有几点需要说明。它们有一个好名字,有几个步骤。你可以按任何方式排序,但这是三个基本组成部分:

  • 作为 [谁]:使用这个功能的人是谁?是你的应用客户、最终用户、管理员、财务人员,还是审查合规性的审计员?根据你的应用,用户角色可能影响不大,也可能完全改变类似功能的性质。
  • 以便 [为什么]:这个功能的目标是什么?我们为什么需要这个功能?这将提醒你不仅仅是功能的“是什么”,还有“为什么”。当你从客户会议转向实际实现时,你需要参考这个“为什么”。客户不会指定应用工作的每一个细节,但“为什么”会帮助你在实际编写代码时做出决策。
  • 我希望 [做什么]:这将是实际应该发生的主要事情。

当然,这只是措辞的框架,你可以根据需要进行调整。这里的想法是,你应该能够拿一个用户故事交给客户,或者交给质量保证团队,他们应该能够阅读这些步骤,测试应用程序,并确认它是可接受的。“可接受”意味着我阅读了描述,并且应用行为与之匹配。

这是一个来自AppFolio的真实例子。在现实世界中,这些通常也使用便利贴,取决于规模。例如:“作为项目经理,我希望每月获得准确的信息系统账单,以便我知道我欠了多少钱。” 这个结构略有不同,但它包含了用户是谁(项目经理),它属于“业务集成”列,所以我们知道它适用于哪个团队,他们需要什么(提供准确账单的工具),以及为什么(作为项目经理,知道欠款很重要)。如果我们问为什么账单重要?因为他们不希望工具被停用。为什么不想被停用?因为如果业务工具离线,可能会严重干扰业务。

为什么使用3x5卡片?📋

为什么使用3x5卡片、便利贴或类似大小的东西?它们简短但易于处理。这源于HCI(人机交互)研究的一般理念。如果你做过参与式设计或用户研究,你会在很多其他形式中看到这一点。关键的一点是它们是实体的、“离线”的。这很重要,因为你可以拿起笔和纸,这是一种没有威胁性的方式。它不是网页上有很多表单的文本框,你不会“写错”。如果你听说过关于Jira的笑话,Jira是一个非常强大的软件,但根据配置方式,它有时会显得非常令人生畏,会出现很多红色错误信息。因为3x5卡片是非电子的,它不会说“嘿,你写错了”。但可以保证,你用来构建软件的工具有时会告诉你“嘿,你做错了”。因此,在与利益相关者合作时,这是获得反馈的重要一环。

当你试图以某种方式组织故事时,小而实体的东西可以成为重新排列它们以提供某种结构的非常有用的辅助工具。我们将讨论如何确定工作优先级这个反复出现的主题。如果你有一叠3x5卡片,一件好事就是把它们摊在桌子上,开始将它们排列成不同的部分,例如“这可能是迭代0”、“这个可能依赖于用户模型的存在,所以这可能是迭代1的故事”。你可以很好地进行物理布局。这就是为什么便利贴是非常常见的东西。有些团队如果有白板,会使用磁性白板卡片;有些人如果使用墙壁,就直接用胶带贴上去。你希望利用实体工具的优势。

它们也很简短,所以我们不会承诺太多。记住,敏捷的重点不是零文档,而是倾向于在深入文档之前先实现一些东西。一张3x5卡片,如果使用得当,可以给你足够的信息开始构建,但它不是一周的工作量,如果你开始构建后发现这张卡片不对或不够清晰,你也不会觉得是在浪费时间。因此,它们是非常有用的工具。

如何制定好的用户故事?🎯

今天我们要回答的问题是:什么是一个好的故事?如何与客户一起制定它们?如何确定它们的优先级?我们将讨论这些。所有这些在某种程度上都是工具,有些在事后看来可能很明显。我们接下来要讨论SMART用户故事,如果你听说过SMART目标,就是由此而来。听起来很容易,但也可以保证,在实践中详细地做这些事情比看起来要难,尤其是当你进入会议,开始有趣的讨论时,你可能会突然意识到花了20分钟讨论是否应该在应用中添加“点赞”功能,或者是否必要。因此,从客户那里获得良好的反馈需要练习。希望我们这里有一些工具可以帮助你。

到目前为止,关于故事、卡片等有什么问题吗?很好,我们打开一个问题进行投票。我们有两个不同的故事和一个简短的问题,没有选项D,但你总是可以选择选项E。

这两个故事是:

  1. 故事A:作为一个剧院观众,为了能和朋友们一起享受演出,我希望看到我的哪些Facebook好友会参加某场演出。
  2. 故事B:作为一个售票处经理,为了吸引顾客购票,我希望展示她的哪些Facebook好友会去看演出。

这是两个关于我们观影应用的类似故事。作为我们应用的实现者,我们应该如何对待它们?再花几秒钟,我们将讨论。

大多数人选择了C(将它们视为两个独立的故事)。在某些情况下,关于哪个是正确答案可能有争论。哪个观点更重要?在这种情况下,将它们保持为独立故事的原因是客户。这里有两种客户。如果你是构建应用的人,你有经理和观影者,他们都是你应用的用户。如果你必须偏向一方,观影者可能更重要,因为他们可能是维持影院和应用开发的人。但售票处经理也是一个重要的角色,根据应用的具体结构,他们可能是委托我们开发应用的人,所以我们也要让他们满意。

另一个关键点是,我们有两个不同的用户。一个是售票处经理,一个是观影者。售票处经理可能不关心Sally的特定朋友以及她为什么和Bob、Alice去看电影,但他们可能关心有多少人在Facebook上分享了信息,也许有一些汇总统计数据。因此,每个功能背后的“为什么”和背景是不同的。在开发过程中,可能会有很多共享的开发工作,你可能一举两得。也就是说,为每种用户类型开发的页面可能有80%是相似的。举个具体例子,当你构建Rails应用时,你会有一个视图,很常见的情况是视图会判断:如果是管理员,渲染这个;如果不是管理员或是其他角色,渲染那个信息。在开发时你可能会这样做。

但我认为,总的来说,将这两个作为独立的故事很重要,因为你捕捉了不同类型的信息。当我们稍后讨论交付故事的“速度”时,如果故事相似,交付更多故事并不是坏事,这也是完全可以接受的。

SMART用户故事 🎯

那么,如果我们有这些指定了用户的故事,如何从中获得最大价值呢?其中一个方法是SMART用户故事。我忘了SMART目标这个短语的起源,但这是一种非常“哈佛商业评论”风格的东西,告诉你应该如何构建事物。这门课有很多缩写,这个希望能帮到你。

SMART代表:具体可衡量可操作相关有时间限制。与SMART目标完全相同的词,但用于SMART故事。

如何将它们付诸实践?具体和可衡量是相关的,关键原因是:具体且可衡量的故事会成为可测试的东西。当你与客户合作时,故事越具体、越可衡量,当你离开客户会议三天或一周后,试图弄清楚实际应该做什么,并问自己“这个完成了吗?我是否交付了客户要求的东西?”时,就会越容易。当然,在这个过程中,你会与他们面对面交流并询问。但故事越可测试、越可衡量(部分源于具体性),你就越容易测试它。这包括手动检查你构建的应用是否做了故事所说的事情。

本周四我们将讨论Cucumber(作业4),你将使用Cucumber,这是一种将故事转化为非常人类可读的测试用例的方法。因此,如果你有一个具体且可衡量的故事,随之而来的将是一组看起来与你写下的故事非常相似的Cucumber测试,这有助于简化应用开发。

举几个例子:

  • “用户界面应该用户友好”:显然,这是一个愚蠢的例子,因为相反的情况(用户界面不应该用户友好)永远不会成为一个故事。但我可以保证,校园里一些构建学生信息系统的人可能达到了那个目标——它们并不是最用户友好的东西。但“用户界面应该用户友好”的问题在于,我们都希望同意这是目标,但这并没有告诉我们“用户友好”意味着什么,没有告诉我们用户是谁、他们应该如何操作应用,而且“用户友好”也真的取决于用户类型。如果你为幼儿园和一年级学生构建应用,你需要与为成年人构建应用(比如B课程)不同的用户友好程度,或者如果你构建Snapchat并想开发新的滑动界面,这对青少年和千禧一代很棒,但对成年人来说可能一点也不用户友好(这可能是他们的意图)。关键是,你需要明确用户是谁,以及什么对他们来说是友好的。
  • 一个好的例子是:“给定[某些条件],当我[做某事]时,那么[其他事情]应该发生。” “给定”、“当”、“那么”实际上将是你经常在Cucumber规范中使用的三个关键词,你也可以在手写用户故事中使用它们。设定一些条件,指定应该发生什么,然后接下来应该发生什么,这给了你一种衡量方式。例如:“给定我是一个新用户,当我注册这个应用时,我将在收件箱中看到一封确认邮件。” 这是一个相当常见的新用户流程用户故事:我注册,得到一些确认。你可以将其转化为测试用例,验证当你以新用户身份运行注册流程时,去检查邮件并看到确认信。

另一个例子:“给定电影票可用,我应该能够购买一张票。” 这个很接近,但它并不总是告诉你用户是谁,也不一定帮助你说明接下来应该发生什么。电影票可用,我应该能够买票。然后呢?是的,你应该能够买票,因为如果不能买票,你的影院就倒闭了。但关键点是,接下来应该发生什么。这个例子更具体,但不一定是可衡量的,因为它没有指定你应该如何买票。提醒一下,其中一些事情在“什么对你和客户最有帮助”方面会有点模糊。但你越具体,这些故事在一两周后你去构建时就越有帮助。

可实现的:这里的目标是每个故事应该是你可以在一个迭代中完成的东西。我们进行为期两周的迭代,并且我们意识到你们还有两门、三门或希望不是四门其他课程。所以你们不会在两周内做40小时的软件开发,而是在一个项目上做两周的兼职工作。如果你以前有过实习经历,你在CS169一个迭代中能完成的工作量与你暑期实习两周能完成的工作量是不同的,因为时间没那么多。

如果你不能在一个迭代中交付,如果你拿到一个故事……以亚马逊为例:“给定我是亚马逊的客户,当我搜索一个产品时,我应该能够购买并将其添加到购物车。” 这涉及到很多步骤,它可能是一个故事。它可能具体,可能可衡量。你可以搜索并将产品添加到购物车,但这将是一个巨大的故事,无法在一周或一个迭代中完成。所以你应该将一个故事分解成多个子故事。分解的最佳方式取决于具体的故事。但将故事分解成更小的具体组成部分没有坏处,然后后来意识到你完成了比计划更多的故事。所以,倾向于选择你更有信心可以实现的小故事,而不是几个你不确定的大故事。

这里的目标是,“可实现的”也意味着在这个故事结束时,你有一个已经编写了代码的功能,有该代码的测试用例,有一个将代码合并到主仓库的拉取请求,并且该拉取请求已被合并和部署。你的故事越小,你就有越多的时间来完成所有额外的步骤。在这个过程中你也会学到,编写初始代码相对容易,而所有关于代码审查、测试、验证工作是否正确的过程则比较耗时。可以告诉你,每家公司都会花费大量时间测试和验证他们内部关于代码审查和测试的处理流程,因为这很难做好,而且不一定有一个正确答案。作为一个团队,你们也会学到这一点。再次强调,目标是你应该每个迭代完成多个故事,但这真的取决于范围。如果你分解它们,你更有可能完成整个用户故事。如果你不能完成一个完整的用户故事,那真的说明它太大了,或者可能出了其他问题。如果发生这种情况,你的GSI会与你的团队沟通。但从小故事开始,完成更多故事,仅仅能够说“我完成了一个故事”所带来的多巴胺冲击也是一件非常好的事情。你的小故事越多,你就越能感觉到进步和完成。

深入挖掘“为什么” 🔍

因此,当你尝试设计故事时,一个技巧就是不断问自己“为什么”。很多人说你应该问五次,取决于这些“为什么”能深入多少层,五次可能足够了。例如:“作为一个售票处经理,为了吸引顾客购票,我希望向他们展示他们的Facebook好友列表。” 为什么我想这样做?因为人们喜欢和朋友一起看电影。为什么这很重要?如果更多人买票,我就能经营更久。在这个例子中,问了两个“为什么”我就大致明白了:好吧,对售票处经理的商业案例来说,赚钱很重要。有时从故事中就能很清楚为什么重要。但总是可以随时与客户交谈并说:“为什么这很重要?” 你可以让他们知道:“我们正在制定用户故事,我们一起合作这个流程。所以我会问你一些问题,有时可能看起来是显而易见的问题,但我没有你所有的背景信息。所以我会问你五次为什么,我们一起完成这个过程。” 这样你就可以真正让客户参与进来。

时间限制 ⏳

对于故事来说,重要的是你能够完成它们。这与“可实现的”相关,但也重要在于,你不仅要知道一个故事太大可能无法实现,还要知道何时停止在一个故事上工作。当我们谈论预算、软件开发成本时,我们通常指的是完成事情所需的时间。有一件事会发生,而且会发生在每个人身上,我敢打赌它可能已经发生在你CS 61B的项目上:你开始做某事,结果花费的时间比你预期的长五倍。所以,一件事是为一个故事设定一个目标,说我们应该能在一定时间内完成,如果在那个时间结束时事情没有达到预期,我们需要重新审视这个故事。这会发生,希望不会一直发生。这是可以预料和理解的。那时的目标就是说,这件事花了太长时间。我如何将这个功能分解成多个更小的故事?我如何学习我之前不知道的东西,以便在合理的时间内完成这个故事?一般来说,目标应该是避免低估一个故事的时长。特别是当你与客户合作时,你希望少承诺、多交付,你永远不想多承诺、少交付,因为那只会让人感到不快。试着给自己留一些余地,估计事情可能需要多长时间。本课程一个故事的典型时间限制当然是一个迭代。

稍后,我们将讨论“速度”这个度量,它只是一个数字,可以帮助你衡量在一个迭代中能完成多少工作,并基于故事的大小。这将帮助你避免低估难度。所以,一旦你构建了几个故事,你就会对下一个故事需要多长时间有感觉。

低保真线框图与故事板 🎨

我们现在所处的位置是,我们已经将低保真线框图放入了构建软件的步骤中。这是我们希望你们能够做的事情。当我们谈论“低保真”时,我们真的是指非常低精度的东西。

构建成功的用户界面很难,大型团队会投入大量精力。因此,从0到漂亮的用户界面是一个巨大的飞跃,你不应该试图在一个迭代中完成,这是一个持续改进的过程。因此,当我们与客户合作决定最高层面应该发生什么时,低保真线框图将是一个非常重要的工具。

我们如何知道应该开始构建什么?低保真线框图将是构建原型的第一步。这里的目标是真正避免出现“我说了,但不是我要的”这种情况。用户会说他们想做某事,你去构建它,然后他们说:“嗯,这不是我想要的。” 低保真线框图是打断这个过程、获得更清晰愿景的一种方式。它们介于3x5用户故事和实现之间,是你们的第一步。

如何在不构建原型的情况下做到这一点?这是教科书中的一个例子。我们有一个电影页面。低保真线框图只是在纸上画一个非常简单的草图。如果你有iPad或Surface配笔,有一些非常棒的应用可以让你画草图并提供一些结构,你完全可以自由使用。但就本课程而言,我们真正鼓励你们做的就是拿一张纸画草图。它可以是白板,然后拍张照片发给客户。

这里发生了什么?我们可以看到这个页面上没有直线。字迹实际上相对清晰,这很好,如果你字迹潦草也不用担心。低保真线框图不是为了赢得任何绘画比赛。事实上,如果你的低保真线框图有非常漂亮的背景阴影和渐变,它们可能保真度不够“低”,它们应该是一个非常粗略的草图。但我们这里有足够的信息说这看起来像一个网页,有一个标题,底部有一个“保存”按钮,还有一个带一些字段的表单。通过看这个,我们对可能发生的事情有了感觉。这是你在思考需要什么、应该发生什么时,几分钟内就能画出来的东西。

这是页面的一个例子。你们要做的就是为每个故事,草拟一页线框图的样子,思考哪些是基本信息。它没有说明你应该使用什么字体,没有说明应用将采用什么配色方案,甚至没有像“这个应该以X、Y或Z方式对齐”这样的细节,或者“当我点击这个时,会有这个动画或弹出窗口”。所有这些你将在应用中构建的东西,你会随着时间的推移而完成。但对于低保真线框图,我们寻找的是非常简单的草图。

我们将把一堆低保真线框图转化为故事板。故事板实际上只是一组带有各种交互形式的场景。例如,我们从这里的“添加到电影数据库”页面开始,这里有一个按钮。然后我们从这个按钮画了一个箭头到另一个低保真线框图。如果你只是在一个屏幕上展示这些,你可以拍张照片并在上面画箭头。如果你与用户合作,一个对原型设计非常有效的技巧是:展示这个页面(这在面对面时效果更好,但也可以在Google Hangouts上模拟),说“点击这些步骤,看看你要做什么”。当他们点击保存按钮时,你将展示的线框图切换成这个页面,即他们刚创建的电影的详细信息视图。这样,在制作原型时,他们会说“我要点击这里”,然后你说“好的,当我们构建应用时,现在会向你展示这个。这有意义吗?” 他们会说:“是的,有意义。当我保存一部电影时,我应该看到我刚创建的东西。” 然后你可以有其他引入更多复杂性的视图。你可以说:“哦,这是详细信息视图。” 然后问他们:“当你点击这里时会发生什么?” 使用低保真线框图的好处是,你实际上不需要构建任何东西。当他们说“我可以点击这里”时,他们可以给出一个非常粗略的理由,因为你们是聪明人,你们可以说:“是的,那将对应这个页面”,甚至在构建任何东西之前。有些人常用的一个工具是PowerPoint,它是制作中等保真交互原型的绝佳方式,但它们比只是简单画草图、通过移动纸张来切换要花费多得多的精力。

这是来自AppFolio的另一个例子,这是几年前的了。他们有一个白板,上面画了很多故事和例子。这是一个设计团队为某个账户管理屏幕构建用户流程的真实例子。这里的细节不重要,但对于实践敏捷设计的公司来说,你也会经历这个过程。所以,这在现实世界中也会发生。这些又是非常基本的屏幕,只是草图,不是整齐的方框,上面只是普通的笔迹,还有很多箭头和注释来说明应该发生什么。这就是低保真线框图真正需要的全部。

“低保真”中的“低”很重要。这里重要的是,一旦你有了低保真线框图,你就有能力将其转化为HTML。当你看到那个表单时,你已经回答了一堆问题:我需要电影标题,我需要表单元素。在Rails中,这很好,因为它们给了你一堆辅助方法来生成这些表单。你有了开始的基础。你的低保真线框图不会指定你应该如何设计样式,但这很大程度上取决于你和你的客户。再次强调,我们在这里寻找的是功能性的应用,而不是最漂亮的应用。但我鼓励每个人都去使用Bootstrap,添加一些非常基本的CSS样式,如果有时间可以自定义,但我们在这里寻找的是可用的、功能性的东西,而不是超级漂亮的东西。

根据过去学生的反馈,低保真线框图和故事板在与客户合作时非常有帮助。频繁的客户反馈至关重要。请记住,你可以非常快速地将低保真线框图交给用户。你会开会,然后与队友讨论,说“我认为我们应该这样做”。然后你可以画点东西,拍张照片,通过电子邮件发给客户,说“这是我们的想法,你觉得怎么样?” 他们会说“很好”,或者说“实际上,既然你给了我更清晰的画面,我认为我真正需要的是这种工具或其他东西”。如果你只投入了五分钟的工作,而客户想改变方向,那也只是你投入的五分钟,而不是五小时。所以,你能用更少的时间学到越多,就越好。

学生反馈与总结

需要强调这一点,我认为这是非常关键的一点。有学生反馈说:“我们做了高保真原型,投入了大量时间,结果才发现客户不喜欢。” 提醒一下,你们会希望东西很漂亮,因为我们使用的应用都投入了数百万美元的开发。即使是获得风投的小型初创应用,甚至是不太好的应用,也投入了数百万美元的开发才达到一个不太好的状态(这可能更多地说明了开发过程)。但要抵制第一次就追求完美的冲动。

另一个提醒是:“从未意识到将客户描述转化为技术计划如此具有挑战性。” 这是你们在构建软件时会继续学习的东西。我上了这门课,了解到它有多难。我去用Grscope构建软件,你仍然会学到它有多难。和这里其他使用Grscope的助教聊天也真的很有趣,我们会聊一个小时,仍然不知道正确的事情是什么。所以,这只是一个持续的提醒:你越能练习尽早获得想法和反馈,效果就越好。你们不会被期望拥有所有正确答案。你们都会遇到构建了东西但客户不喜欢的情况。那会是一种糟糕的感觉。没关系。接受你已有的,并从中学习。我构建了某个东西,客户不喜欢。我能做些什么来让这个过程变得更好?

课程总结

在本节课中,我们一起学习了行为驱动设计(BDD)和用户故事的核心概念。我们了解了如何将客户需求转化为具体、可衡量、可操作、相关且有时间限制的SMART用户故事。通过使用3x5卡片和低保真线框图,我们掌握了与客户有效沟通、快速验证想法的工具,从而避免在错误方向上过度投入。这些实践将帮助你们在接下来的团队项目中,更系统、更高效地进行需求分析和迭代开发。

010:故事点、速度与Cucumber测试

在本节课中,我们将学习如何将用户故事转化为可执行的任务,并使用敏捷开发中的“故事点”和“速度”概念进行工作量估算与进度管理。我们还将介绍Cucumber和Capybara这两个强大的工具,它们能将用户故事直接转化为自动化测试,确保软件功能符合客户预期。

故事点与速度估算

上一节我们讨论了如何从客户那里收集需求并编写用户故事。本节中,我们来看看如何为这些故事估算工作量,并规划团队的工作节奏。

故事点的作用

故事点的目标是帮助我们估算生产力。它们帮助团队衡量在两周的迭代周期内可以完成多少工作,并有助于与客户沟通可交付的工作量,避免过度承诺。同时,这也是一个练习如何估算工作量的过程。

如何估算故事点

故事点是任意值,没有单位。在本课程中,我们推荐使用一个简单的尺度:1, 2, 4, 8。这个尺度类似于T恤尺码(XS, S, M, L)。

  • 1点:大约相当于半天的专注编码时间(3-4小时)。
  • 2点:大约相当于一整天的专注编码时间。
  • 4点和8点:代表更复杂、不确定性更高的工作。我们应尽量避免出现8点的故事,如果遇到,通常意味着需要将其拆分成更小的故事。

估算时,团队应一起进行。推荐的方法是“计划扑克”:每个成员独立估算一个故事的点数,然后讨论差异。如果估算值存在差异,应选择较高的那个值,因为认为需要更长时间的人可能预见到了其他人忽略的难点。

什么是“史诗”?

当一个故事太大,无法在一个迭代内完成时,我们称之为“史诗”。史诗是一个共享功能的较小故事的集合。例如,“用户可以通过任何第三方服务登录”可能是一个8点的史诗,可以拆分为“通过Facebook登录”、“通过Google登录”等独立的故事。

理解“速度”

速度是一个速率,表示团队在单位时间(如每周或每个迭代)内平均能完成的故事点总数。公式可以表示为:
速度 = 已完成的故事点总数 / 迭代周期数

速度的目标不是衡量团队的“快慢”,而是帮助团队内部建立一致的工作节奏和预测能力。不同团队之间的速度无法直接比较

如何拆分大故事:使用“刺探”

当你面对一个不确定如何实现的大故事时,可以进行一次“刺探”。刺探是指分配一个固定的时间盒(例如一天),用于快速构建一个粗糙的原型或进行研究,目的是探索未知领域。刺探结束后,代码通常会被丢弃。它的价值在于获取信息,以便更准确地将大故事拆分为可管理的小故事。

使用Pivotal Tracker进行项目管理

了解了如何估算故事后,我们需要一个工具来管理这些故事和整个工作流程。我们将使用Pivotal Tracker。

Tracker界面与工作流

在Tracker中,故事从左到右推进,代表从开始到完成的流程。

  • 冰盒:最右侧的列。存放尚未确定优先级或未开始的故事,用于记录任何初步想法。
  • 待办列表:中间的列。存放已确定优先级并估算好点数的故事,它们已准备好被领取开发。
  • 当前工作:最左侧的列。存放团队当前正在积极开发的故事。

团队应始终保持当前工作列中有3到6个活跃的故事(假设6人团队进行结对编程)。待办列表顶部的故事是下一步应该开始工作的故事。

故事状态周期

一个故事在Tracker中会经历以下状态:

  1. 已创建 -> 已估算(团队分配故事点)
  2. 已确定优先级 -> 已分配(产品负责人分配给具体开发者或结对)
  3. 已开始 -> 已完成(代码编写、测试通过、代码审查完成)
  4. 已交付(代码已合并并部署到测试或生产环境)
  5. 已接受(客户验证功能符合要求后确认)

只有客户(或代表客户的产品负责人)可以“接受”一个故事。

团队角色:产品负责人

在敏捷开发中,产品负责人是一个关键角色。在本课程中,这个角色将在团队成员间轮换。产品负责人负责:

  • 在团队会议上代表客户的声音。
  • 主持故事点估算会议。
  • 根据客户反馈确定待办列表的优先级。
  • 将故事分配给开发成员。
  • 最终与客户确认故事完成并点击“接受”。

使用Cucumber和Capybara编写验收测试

现在,我们有了清晰的故事和开发计划。本节我们将学习如何将这些用户故事转化为可自动运行的测试,确保开发结果符合预期。

Cucumber简介

Cucumber是一个工具,它能将用近乎自然语言编写的用户故事(称为“特性”)转化为可执行的测试。它是连接客户需求与自动化测试的桥梁。

特性文件的结构

Cucumber测试写在.feature文件中。每个文件对应一个功能特性,包含多个场景。

一个基本的场景结构如下:

功能: 用户能够添加电影
  场景: 成功添加一部新电影
    假如 我在首页
    当 我点击“添加新电影”链接
    并且 我在“标题”字段中输入“黑客帝国”
    并且 我点击“保存”按钮
    那么 我应该看到消息“电影创建成功”
    并且 我应该在电影列表中看到“黑客帝国”

核心关键字:

  • 假如:设置测试的初始状态和前提条件。
  • :描述用户执行的操作。
  • 那么:断言操作后的预期结果。
  • 并且/但是:用于连接多个假如那么步骤,使阅读更流畅。

步骤定义

.feature文件中的每一步都需要用Ruby代码在step_definitions目录中实现,这就是“步骤定义”。Cucumber使用正则表达式将特性文件中的文本行映射到这些Ruby方法上。

例如,对于步骤当 我在“标题”字段中输入“黑客帝国”,其步骤定义可能如下:

当(/^我在“([^"]*)”字段中输入“([^"]*)”$/) do |field_name, value|
  fill_in(field_name, with: value)
end

这里,fill_in是Capybara提供的方法,用于在网页的指定字段中填充内容。

Capybara的作用

Capybara是一个模拟用户与网页交互的库。它在Cucumber步骤定义中被调用,可以点击按钮、填写表单、检查页面内容等。它运行在一个真实的浏览器环境(或无头浏览器)中,因此能测试包括JavaScript在内的整个应用栈,是一种强大的系统测试/验收测试工具。

编写好的场景

  • 测试“快乐路径”和“悲伤路径”:不仅要测试功能正常的情况,也要测试各种出错情况(如输入无效数据)。
  • 场景应保持简短:每个场景最好包含3到8个步骤。
  • 描述行为,而非实现:步骤应描述“做什么”,而不是“怎么做”(例如,用“我应该看到成功消息”,而不是“我应该看到div#success元素”)。

总结

本节课中我们一起学习了敏捷开发中规划与测试的核心实践。

我们首先介绍了如何使用故事点来估算用户故事的工作量,并理解了速度作为团队内部节奏指标的意义。我们知道了如何用Pivotal Tracker这个工具来管理故事的生命周期,从冰盒到最终被客户接受。

接着,我们探讨了如何通过CucumberCapybara将用户故事转化为可执行的自动化验收测试。这确保了我们的代码不仅能用,而且符合客户描述的行为预期。

掌握这些概念和工具,将帮助你更系统化、更高效地与团队协作,并交付真正满足客户需求的软件。

011:BDD、Cucumber与客户会议

概述

在本节课中,我们将继续学习BDD(行为驱动开发)和Cucumber测试。我们将深入了解Cucumber的步骤定义、Capybara的交互方式,并探讨如何有效地与项目客户进行沟通和会议。课程的后半部分将预留时间进行期中考试答疑。

BDD与Cucumber测试回顾

上一节我们介绍了BDD的基本概念。本节中,我们来看看Cucumber测试框架的具体实现细节。

Cucumber是一个允许我们以接近英语的方式编写测试的框架。它建立在Capybara工具之上,Capybara负责与Web浏览器进行交互。在开发环境中,整个测试栈连接到一个正在运行的应用副本和数据库。

以下是测试栈的结构:

  • Cucumber:管理特性文件(.feature)和步骤定义(Ruby文件)之间的交互。
  • Capybara:自动化与Web浏览器的交互。
  • Rails应用与数据库:被测试的实际应用。

周四的课程将深入讲解RSpec,这是一个用于单元测试的工具,它提供了断言和期望的语法。

Cucumber步骤与正则表达式

步骤定义基于正则表达式。你应该在本课程中熟悉它们。虽然我们不会就复杂的正则表达式进行测验,但它们是一项非常有用的技能,建议通过实践和搜索来掌握。

与页面组件交互

Cucumber和Capybara提供了与表单字段、按钮等页面组件交互的API,例如 fill_infind。随着实践,你会逐渐熟悉这些方法。

核心流程是:编写一个响应特定模式的步骤定义,与页面元素进行交互,然后检查输出结果。

场景与步骤定义的位置

以下是关于文件位置的问题解答:

  • 场景本身位于特性文件(.feature)中。这些文件看起来是纯文本,使用 GivenWhenThen 等关键字。
  • 步骤定义位于 Ruby文件 中,通常放在 step_definitions 文件夹下。

Cucumber的工作机制

Cucumber是一个管理特性文件步骤定义之间交互的框架。它的工作是尝试将特性文件中的步骤与你作为程序员定义的正则表达式进行匹配。

Capybara则是管理与Web浏览器(或模拟浏览器)交互的工具。了解这个区别有助于在遇到错误时更准确地搜索解决方案。

匹配步骤定义

步骤定义使用正则表达式来匹配场景中的步骤。用双引号包裹变量部分是一个好习惯,这有助于区分输入参数和固定文本,使场景和步骤定义更易读。例如,Given my birthday is set to "May 12, 1990" 可以匹配步骤定义 /^my birthday is set to "([^"]*)"$/,并将 "May 12, 1990" 作为参数捕获。

BDD工作流程:从红到绿

在项目中使用Cucumber的典型流程是:首先编写失败的测试(红色),然后实现功能使测试通过(绿色)。Cucumber这个名字的寓意是“绿色测试是好的”,就像绿色蔬菜有益健康一样。

Cucumber的高级功能

Cucumber本身及其周边工具提供了许多功能来简化测试。

1. Background(背景步骤)

Background 关键字用于定义在每个场景之前运行的共享设置步骤。这避免了在每个场景中重复相同的准备步骤。在RSpec中,类似的工具有 beforeafter 钩子。

2. 表格数据

Cucumber支持使用管道符(|)定义的表格数据。它会自动将表格解析成哈希数组,方便在步骤定义中使用。这对于填充多个表单字段或创建一组数据特别有用。

3. 时间测试(Timecop)

对于处理时间敏感数据的应用,Timecop gem 非常有用。它允许你“冻结”或“穿越”到特定时间进行测试,确保测试结果不依赖于运行时的实际时间。

Capybara 交互指南

Capybara 与浏览器中的页面进行交互。页面就是一个HTML文档,包含各种元素。

以下是一些核心方法:

  • find:最常用的方法。可以通过CSS选择器、ID或XPath来查找元素。例如:find('#username')
  • have_csshave_text:这些匹配器内部使用 find,并返回是否找到元素的布尔值。例如:expect(page).to have_css('.user-list')
  • 作用域查找:可以先找到一个父元素,然后在其内部查找。例如:within('.sidebar') { find('.menu-item') }

测试专用CSS选择器

一个有用的技巧是使用专为测试设计的CSS类(例如,以 .js-.test- 为前缀)。这样做可以将测试逻辑样式逻辑分离,当改变页面样式时,无需同时修改大量测试代码。

客户会议指南

现在,让我们从技术测试转向项目协作。与客户进行有效沟通是项目成功的关键。

沟通频率与团队协作

来自往届学生的建议表明,频繁的沟通至关重要。对于六人团队,协调日程可能具有挑战性。

  • 考虑每日进行简短站会(例如通过Slack或五分钟视频通话)。
  • 每周仅一次会议可能不够。更短、更频繁的同步更有效。
  • 团队沟通是软件工程中持续存在的挑战,多加练习会有所帮助。

会议准备与执行

以下是为客户会议做准备的一些通用建议:

会前准备:

  • 设定议程:客户应提前知道会议内容。
  • 发送材料:如果可演示,提前将应用访问方式或截图发给客户,让他们有时间测试并提供具体反馈。
  • 准时开始:尊重客户时间,准时开始会议。

会议期间:

  • 保持专注:全身心投入会议,不要 multitasking。
  • 专人记录:指定一人做会议记录。
  • 获取优先级:最重要的任务之一是让客户明确优先级。客户可能有很多需求,但你们时间有限。询问:“哪一两项是最重要的?”
  • SAMOAS原则
    • Start and stop on time (准时开始和结束)
    • Agenda (议程)
    • Minutes (记录)
    • One speaker at a time (一次一人发言)
    • Advance materials (提前发送材料)
    • Set next date (设定下次会议日期)

会后跟进:

  • 整理记录:将会议记录和行动项更新到项目追踪工具(如Pivotal Tracker)中。
  • 及时沟通:完成行动项后及时告知客户。可以利用Slack的提醒功能。

迭代零会议

与客户的第一次会议通常是“迭代零”会议。目标是:

  • 互相认识,建立关系。
  • 高层次了解项目需求和背景。
  • 不要在第一次会议后就详细规划所有用户故事。那是后续会议的工作。
  • 对于新启动的项目,这次会议对于理解客户需求尤为关键。

远程会议建议

如果需要进行远程会议:

  • 面对面优先:能线下则线下,沟通效率更高。
  • 使用耳机:显著提升音频质量。
  • 集体参会:如果客户远程,团队其他成员最好在同一房间共用一套设备接入,避免多人独立接入造成的混乱。
  • 减少共享切换:提前准备好要演示的内容,尽量减少屏幕共享的频繁切换。

期中考试答疑与补充主题

课程剩余时间用于解答问题。涵盖的主题包括软件工程、Ruby on Rails等。

Web服务器 vs. 应用服务器

  • Web服务器(如Nginx, Puma, Unicorn):处理原始的HTTP请求/响应,解析协议。
  • 应用服务器(如Rails):包含MVC业务逻辑、路由定义等。Rails运行在Web服务器之上。在本课程中,我们主要关注应用服务器(Rails)层面。

Rails路由助手

使用 resources :photos 会在 routes.rb 中生成一套RESTful路由,并创建相应的路径助手方法(如 photos_pathnew_photo_path)。
要查看应用中的所有路由,可以在终端运行命令:

bundle exec rake routes

这会列出所有路径助手、HTTP动词、URL模式和对应的控制器动作。

Sinatra 与 Rails 对比

  • Sinatra:轻量级框架,适合构建简单的Web应用或API。它主要提供路由功能,没有内置的MVC结构或ORM,所有代码通常集中在少数几个文件中。
  • Rails:全栈框架,提供大量“开箱即用”的功能和约定。
    • 结构:强制性的MVC文件夹结构(app/modelsapp/viewsapp/controllers),便于组织大型项目。
    • 约定优于配置:通过命名约定(如 PhotosController 对应 Photo 模型),减少配置。
    • Active Record:强大的ORM,将Ruby对象映射到数据库,抽象了大部分SQL操作,是Rails的核心优势之一。

命令式 vs. 声明式步骤

在编写Cucumber步骤时,有两种风格:

  • 命令式:一步步模拟用户的实际操作(如“填写字段”、“点击按钮”)。这能更真实地测试用户流程。
  • 声明式:直接设置应用状态(如通过Active Record直接创建数据库记录)。这通常更快,编写起来也更简单。
    两者结合使用,可以在测试覆盖率和执行效率之间取得平衡。

首次客户会议时间

建议尽早安排第一次客户会议(例如本周)。越早开始,就能越早熟悉项目,并暴露出理解上的差距,从而能更快地带着更具体的问题进行后续沟通。可以安排一个简短的介绍性会议(例如30分钟)来破冰并了解概要。

团队成员无法参会

尽量确保所有成员都能参会。如果个别成员因时间冲突无法参加:

  • 确保参会者做好详细记录并共享。
  • 让未能参会的成员与参会者结对工作,保持同步。
  • 考虑轮流调整会议时间,或为无法参会的成员安排单独的同步会议。

总结

本节课我们一起深入学习了Cucumber测试框架的细节,包括步骤定义、高级功能(Background、表格数据)以及Capybara的页面交互方法。接着,我们探讨了与项目客户进行有效会议的关键策略,包括会前准备、会中执行和会后跟进的最佳实践。最后,我们针对期中考试可能涉及的一些概念(如Web服务器架构、Rails路由、框架对比等)进行了答疑和梳理。掌握这些测试工具和沟通技巧,将为你们接下来的项目开发工作奠定坚实的基础。

012:测试理论与实践 🧪

在本节课中,我们将学习软件测试的不同层次、最佳实践以及如何编写有效的测试用例。我们将探讨从高层次的Cucumber验收测试到低层次的RSpec单元测试,并理解如何利用测试驱动开发(TDD)和行为驱动开发(BDD)来构建更健壮的应用程序。


课程更新与项目安排 📅

期中考试的情况比预期更令人紧张,但这没关系。由于停电,我们未能按计划完成期中考试的批改工作,对此表示歉意。成绩将在本周内公布。课程大纲将相应调整,但总体计划有弹性,因此不会增加额外的课程或占用考试周的时间。课程项目将替代期末考试。

关于项目,本周应开始与客户进行初次会面,讨论项目需求。目标是理解客户对整个学期项目的期望。如果是回头客,可以询问过往经验以获得改进建议。从本周起,还有两次作业,大部分工作将集中在项目上。


测试层次与最佳实践 🎯

上一节我们介绍了课程安排,本节中我们来看看测试的不同层次及其最佳实践。

测试是让程序员生活更轻松的重要工具。我们花了很多时间学习Cucumber,它是一种非常有用的工具,但像任何工具一样,它不能完全解决问题,你需要知道如何实践。

Cucumber使用注意事项

以下是使用Cucumber时需要注意的几点:

  1. 测试“快乐路径”与异常路径:不仅要测试正常流程(如成功登录),还要测试可能出错的场景(如输入错误)。
  2. 小心正则表达式:过于宽泛的正则表达式(如 .*)可能匹配到不期望的内容,导致多个步骤定义相互冲突。应使其更具体。
  3. 避免过度复杂的正则表达式:不要为了共享代码而编写复杂的正则表达式。如果需要共享代码,应将步骤定义拆分为多个方法。
  4. 注意匹配位置:使用 within 选择器来限定文本搜索的范围,例如 within(‘.user-profile-card’),这能使测试意图更清晰。
  5. 验证实际结果:确保测试检查的是实际被触发的内容。有时可以直接在步骤定义中检查测试数据库的状态。

测试类型:集成、功能与单元测试

在软件测试中,我们通常将测试分为三个层次:

  • 集成测试 / 验收测试:这是最高层次的测试,通常使用Cucumber编写。它模拟完整的用户故事,涉及多个页面和交互步骤(例如,登录、添加商品到购物车、结账、收到邮件)。
  • 功能测试 / 模块测试:这一层次测试应用程序的较大组成部分,但通常局限于单个页面或控制器。它仍然涉及大部分技术栈,但不是完整的端到端用户流程。
  • 单元测试:这是最底层的测试,专注于测试独立于应用程序其他部分的、特定的小功能单元。我们将使用RSpec来编写这类测试。

注意:在业界,这些术语有时会混用(例如,有人将功能测试等同于集成测试)。关键在于理解我们是在不同隔离级别上测试代码。


测试类型选择练习 🤔

为了加深理解,我们通过几个场景来选择最合适的测试类型。

场景一:未登录访问个人资料页

描述:当用户未登录时访问个人资料页,应被重定向到登录页面。最适合的测试类型是什么?

分析:这个场景主要测试一个特定的控制器动作(重定向)。它不涉及多页面流程,只关心单个请求的响应。因此,功能测试(B) 更为合适。你可以使用RSpec的 redirect_to 辅助方法来验证重定向。如果测试还涉及重定向后页面的具体内容,则可能需要Cucumber集成测试。

场景二:用户注册(含验证码)

描述:用户通过提供邮箱、密码并完成验证码来注册新账户。最适合的测试类型是什么?

分析:这个流程包含交互元素(验证码)并可能涉及多个步骤和页面状态变化。为了测试完整的用户交互流程,Cucumber集成测试(A) 是最佳选择。当然,可以结合单元测试来验证具体的业务逻辑(如密码验证)。

场景三:密码非空验证

描述:用户设置账户时不能指定空密码。最适合的测试类型是什么?

分析:这纯粹是数据模型层面的验证逻辑。它不涉及控制器或视图。因此,最适合使用 单元测试(D) 来直接测试用户模型中的验证方法。


测试驱动开发(TDD)与良好测试原则 🚀

上一节我们探讨了如何选择测试类型,本节中我们来看看如何通过TDD来组织测试,以及良好测试的原则。

行为驱动开发与测试驱动开发

Cucumber主要用于行为驱动开发。我们先编写描述用户行为的高级场景,然后编写代码使其通过。然而,当Cucumber测试失败时,定位问题可能比较困难。

因此,我们通常将Cucumber场景与更细粒度的RSpec测试(单元和功能测试)结合使用。这就是测试驱动开发的模式:红-绿-重构

  1. :先编写一个会失败的测试。
  2. 绿:编写尽可能简单的代码使测试通过。
  3. 重构:在测试保护下改进代码质量。

TDD的核心理念是:首先追求功能正确的代码,然后再优化其设计和可读性。DRY(不要重复自己)是一个好习惯,但不必一开始就追求完美的抽象。

良好测试的F.I.R.S.T.原则

优秀的测试用例应遵循F.I.R.S.T.原则:

  • 快速:测试应该快速运行,以便频繁执行。
  • 独立:测试不应依赖于其他测试或外部状态(如当前日期、数据库残留数据)。
  • 可重复:在任何环境下多次运行都应得到相同的结果。
  • 自验证:测试应能自动判断通过与否,无需人工干预。
  • 及时:测试最好与代码同时或提前编写。

处理测试中的依赖与不确定性 🎲

上一节我们介绍了良好测试的原则,本节中我们来看看测试中的一个常见挑战:如何处理依赖和不确定性。

考虑一个方法 birthday_today?,它检查今天是否是用户的生日。直接使用 Date.today 会导致测试只在一年中的某一天通过,这违反了独立可重复原则。

场景:测试随机性和时间依赖

问题:如何测试依赖随机数或当前时间/日期的代码?

分析:答案是我们可以让这些非确定性因素变得确定性

  • 随机数:使用固定的种子来初始化伪随机数生成器,这样每次生成的序列都是相同的。
  • 日期/时间:使用模拟存根技术。在测试设置中,我们可以“欺骗”系统,让它认为当前是一个特定的日期(例如,总是10月15日)。

RSpec默认以随机顺序运行测试,并会输出使用的种子号。如果遇到因测试顺序导致的偶发失败,可以利用这个种子号进行调试。


RSpec单元测试基础 📝

现在,让我们深入了解一下RSpec单元测试的基本结构。

测试结构:Arrange, Act, Assert

一个好的单元测试通常包含三个部分:

  1. Arrange:设置测试环境和所需数据。
  2. Act:执行被测试的操作。
  3. Assert:验证操作结果是否符合预期。

在RSpec中,expect 是进行断言的主要方式。

RSpec示例:斐波那契数列

# 引入必要的文件
require ‘rspec’
require ‘fibonacci’

describe Fibonacci do
  it “正确计算第五个斐波那契数” do
    fib = Fibonacci.new
    expect(fib.calc(5)).to eq(5) # 假设序列为 1, 1, 2, 3, 5
  end

  it “将浮点数输入转换为整数” do
    fib = Fibonacci.new
    expect(fib.calc(5.7)).to eq(5)
  end

  it “对负数输入返回错误” do
    fib = Fibonacci.new
    expect { fib.calc(-1) }.to raise_error(ArgumentError)
  end
end
  • describe 用于组织相关的测试用例。
  • it 定义一个具体的测试用例,字符串描述其目的。
  • expect(...).to eq(...) 是最基本的断言形式。
  • expect { ... }.to raise_error(...) 用于断言会抛出异常。

更复杂的RSpec结构

describe BookInStock do
  it “类应该存在” do
    expect(BookInStock).to be_a(Class)
  end

  describe “getters and setters” do
    before do
      # Arrange: 为这个describe块内的所有测试进行通用设置
      @book = BookInStock.new(“123”, 33.95)
    end

    it “应该设置ISBN” do
      # Act & Assert
      expect(@book.isbn).to eq(“123”)
    end

    it “应该设置价格” do
      expect(@book.price).to eq(33.95)
    end
  end
end
  • before 钩子在每个 it 块运行前执行,用于公共的 Arrange 步骤。
  • 每个 it 块最好只测试一个具体的期望,这样测试报告会更清晰。

使用模拟与存根隔离测试 🔗

在最后一节,我们探讨如何通过模拟和存根来编写独立、快速的测试。

找到接缝并模拟依赖

应用程序中的接缝是指那些行为受外部代码(如API调用)影响的地方。为了测试依赖于接缝的代码(例如,一个调用外部电影数据库API的控制器),我们不应该在单元测试中真正发起网络请求。

解决方案:使用RSpec的 expect(...).to receive(...) 来模拟方法调用。

示例:模拟外部API调用

假设我们有一个控制器动作 search,它会调用模型方法 Movie.find_in_tmdb 来查询外部API。

我们想测试控制器逻辑,而不依赖真实的API。以下是控制器测试的思路:

describe MoviesController do
  describe “搜索TMDB” do
    it “应该调用模型方法来执行搜索” do
      # 1. 在调用控制器动作前,设置期望:Movie类应该收到 `find_in_tmdb` 方法调用
      expect(Movie).to receive(:find_in_tmdb).with(“hardware”)

      # 2. 执行控制器动作(Act)
      post :search, params: { search_term: “hardware” }

      # 3. 如果上一步的 `post` 成功调用了 `Movie.find_in_tmdb(“hardware”)`,则测试通过。
      # RSpec会自动验证期望是否满足,无需额外的assert语句。
    end
  end
end

为什么期望要写在动作之前?
因为 expect(...).to receive(...) 会预先设置一个“间谍”来监听方法调用。它记录该方法是否被调用、调用参数是什么。只有在设置了这个期望之后执行代码,监听才会生效。

这种方法使得测试:

  • 独立:不依赖外部网络和服务。
  • 快速:没有真实的网络延迟。
  • 可重复:每次结果一致。

总结 📚

本节课中我们一起学习了:

  1. 测试的层次:从高层的Cucumber集成测试,到功能测试,再到底层的RSpec单元测试,每种测试适用于不同的场景。
  2. 测试驱动开发:遵循“红-绿-重构”循环,先写测试,再写实现代码,最后重构优化。
  3. 良好测试原则:测试应遵循F.I.R.S.T.原则(快速、独立、可重复、自验证、及时)。
  4. 处理不确定性:通过固定种子、模拟时间或存根方法,将非确定性的依赖转化为确定性的测试。
  5. RSpec基础:学习了 describe, it, before, expect 等关键结构来编写单元测试。
  6. 测试隔离:通过模拟(expect to receive)来隔离外部依赖,使单元测试更专注、更快速。

掌握这些测试理念和工具,将帮助你构建出更可靠、更易于维护的软件。接下来的作业将围绕测试展开,请积极应用这些知识。

013:测试技术与策略进阶 🧪

在本节课中,我们将继续深入学习测试技术、工具和策略,以有效地测试我们的Rails应用程序。目标是学会如何在不必测试所有可能场景的情况下,对我们的测试用例建立足够的信心。

继续讨论测试技术

上一节我们介绍了测试的基本概念,本节中我们来看看如何利用“接缝”和“替身”来编写更健壮、更专注的测试。

理解“接缝”

“接缝”是指应用程序中那些输入可以改变行为的地方。例如,我们可能进行第三方API调用或依赖某些外部结果。这些地方是测试的重点。

核心思想是:我们关注一个方法内部应该发生什么。例如,对于一个控制器方法,我们关心它应该接收什么数据作为输入,以及它应该返回什么。对于它进行的API调用和数据处理,我们可以使用模拟和存根等工具来编写测试用例,甚至在实现细节尚未确定之前就可以进行。

我们称之为“由外向内”的测试:从一个非常高的层次开始,在测试用例中假设某些代码已经工作,然后专注于测试控制器代码需要完成的部分。在另一个测试用例中,我们再专注于那个进行API调用或处理数据的特定函数。

关键点在于打破依赖。控制器方法有一个职责,API调用有另一个职责。我们称这些为“接缝”——应用程序行为基于某些外部方法而改变的地方,例如参数或API调用。

使用模拟和存根

让我们看一个具体的例子。假设我们有一个方法 find_in_tmdb,它调用一个尚未实现的外部服务。我们可以让Ruby断言这个方法被正确调用,但不必担心实际调用它。这样,即使方法尚未实现,我们的测试仍然可以通过。

以下是相关代码示例:

# 在测试中设置期望
expect(Movie).to receive(:find_in_tmdb).with(hardware: 'search_term')
# 执行控制器动作
post :search_tmdb, params: { search_term: 'hardware' }

在这个例子中,我们断言 find_in_tmdb 方法被调用,并且传入了正确的参数 search_term: ‘hardware’。即使 find_in_tmdb 方法本身还不工作,我们也能确信控制器逻辑是正确的。

测试的价值

你可能会想,控制器方法只是简单地将参数传递给模型方法,为什么需要测试?随着应用程序变得复杂,测试的价值会显现出来。测试用例可以防止你犯拼写错误。专业程序员也经常犯拼写错误,测试是发现这些错误的绝佳起点。

实现后的测试策略

当我们最终编写了 find_in_tmdb 方法的真实实现后,应该如何调整我们的测试用例?

我们有两个主要选项:

  1. 保留原来的模拟调用(即 expect 语句),不实际调用真实方法。
  2. 用对 find_in_tmdb 的真实调用替换这个期望。

两者都有价值。但如果我们希望测试仅针对控制器方法,我们应该保留模拟调用的期望,并在需要时更新参数。这样做可以隔离我们的控制器测试与模型的实际实现。如果 find_in_tmdb 的测试失败,控制器测试不会因此失败,这有助于我们快速定位问题所在。

正确的做法是:我们最终会有一个单独的测试来覆盖 find_in_tmdb 方法的正确行为。这样我们就解耦了测试用例,让每个测试只关注一个功能点。

使用“替身”

“替身”是我们用来使测试更健壮、无需实现整个应用程序的工具。它们有时也被称为模拟对象或存根。具体术语可能因框架而异。

在RSpec Rails中,assigns 方法非常有用,它可以让我们创建假对象来指定它们应有的行为。例如,我们可以检查控制器是否正确地给实例变量 @movie 赋值。

另一个常用工具是 instance_double。它创建一个类的实例(如一个具体的电影对象),但其方法是存根化的,不会调用真实的方法。我们可以指定它有一个特定的标题,而无需该电影真实存在于数据库中。

目标是设置满足特定测试用例所需的最少信息的场景。这使得测试易于编写和阅读。

以下是如何使用 instance_double 和存根的示例:

# 创建一个电影的实例替身
m = instance_double(‘Movie’)
# 存根 title 方法,使其返回 ‘Snowden’
allow(m).to receive(:title).and_return(‘Snowden’)

现在,任何调用 m.title 的代码都会得到 ‘Snowden’ 这个返回值。我们可以存根任何我们需要的方法。

固件与工厂 🏭

到目前为止,我们讨论了替身、模拟和存根。它们的目的是在编写单个测试用例时进行设置。而固件工厂是让我们创建真实、可操作对象实例的工具。

固件

固件是一种在运行测试用例之前就指定一些数据设置的方式。Rails提供了很好的机制来使用固件,它们通常存储在YAML文件中。

例如,一个 movies.yml 文件可能如下所示:

milky_movie:
  title: ‘Milky Way Documentary’
  rating: ‘G’
  description: ‘A journey through our galaxy.’

在测试文件顶部,我们可以使用 fixtures :movies 来加载这些数据。Rails提供了一个便捷的方法来获取特定固件:movies(:milky_movie)

使用固件的一个巨大优势是,它可以与数据库清理工具(如 database_cleaner)配合,确保每个测试都在一个干净的数据库状态下运行,使测试相互独立。

工厂

工厂则用于按需即时创建对象。它们允许我们在每个独立的测试中创建具有特定属性的用户或其他对象。

一个流行的工具是 factory_bot 宝石。它提供了简洁的语法来定义和创建工厂。

定义工厂的示例:

FactoryBot.define do
  factory :movie do
    title { ‘Default Movie Title’ }
    rating { ‘PG’ }
  end
end

在测试中使用:

# 创建一个电影实例(不保存到数据库)
movie = build(:movie)
# 创建一个电影实例并保存到数据库
movie = create(:movie, title: ‘Custom Title’)

build 方法给出一个我们可以操作的对象实例,而 create 方法会构建对象并持久化到数据库。

如何选择

固件和工厂各有优劣,可以结合使用:

  • 固件适合预加载一组通用的、关系复杂的数据。
  • 工厂适合在测试中动态创建具有特定属性的对象。

选择哪种取决于你的应用程序结构和测试需求。

测试的覆盖度与层次 📊

我们应该进行多彻底的测试?这是一个需要持续权衡的问题。零测试显然不够,但过度测试也会拖慢开发速度。

代码覆盖率

代码覆盖率是衡量测试完整性的一个指标。它关注有多少代码行在测试中被执行。常用的工具有 simplecov

覆盖率有不同的层次:

  • C0(语句覆盖):每个语句是否都被执行了?这是最基本的一层,工具可以很好地测量。
  • C1(分支覆盖):每个条件分支(如 if/else)是否都被执行了?
  • 更高层次:如路径覆盖等,测量起来更复杂,有时也不那么可靠。

我们的目标通常不是100%(这常常不切实际),而是保持在一个较高的水平(例如85%-90%)。覆盖率报告可以帮助我们识别未经测试的代码路径。

测试的层次

测试有不同的层次,每种都发现其他层次可能遗漏的Bug:

  1. 单元测试:测试单个方法或函数。它们运行快关注点单一,提供详细的错误定位。但只靠单元测试无法保证组件间的协作。
  2. 集成测试:测试多个组件如何协同工作(例如使用Cucumber或Capybara)。它们速度较慢,但能捕捉用户工作流程中的问题。当它们失败时,错误原因可能不那么直观。
  3. 功能/模块测试:介于两者之间,测试一个模块或功能单元。

目标是混合使用不同层次的测试。单元测试提供高覆盖率和细节;集成测试确保核心用户流程正常工作。

在何处使用模拟

在不同的测试层次,模拟的使用策略也不同:

  • 单元测试中,我们会更多地使用模拟和替身来隔离当前测试的单元。
  • 集成测试中,我们应尽可能少用模拟,以便测试更真实的场景。

对于外部API调用,我们通常希望模拟它们,以使测试不依赖网络连接,并返回可控的响应。有些服务(如Stripe)会提供专门的测试环境和测试数据,这也是一个很好的选择。

测试驱动开发建议 ✅

最后,关于测试驱动开发,哪条是糟糕的建议?

  • A. 在单元测试中使用替身。 (好建议)
  • B. 追求高的单元测试覆盖率。 (好建议,但100%不现实)
  • C. 在单元测试中使用模拟和存根是可以的。 (好建议)
  • D. 仅靠单元测试就能给你最高的信心。 (糟糕的建议)

D是糟糕的建议。正如我们讨论的,你需要单元测试和集成测试的组合才能获得最高的信心水平。

总结

本节课中我们一起学习了:

  • “接缝”的概念及其在隔离测试中的重要性。
  • 如何使用模拟存根(如 expectallowinstance_double)来编写不依赖外部实现的测试。
  • 在方法实现后,如何调整测试策略以保持隔离性。
  • 固件工厂的区别与用途,以及如何利用它们创建测试数据。
  • 代码覆盖率的层次和意义,以及如何将其作为改进测试的指南。
  • 不同测试层次(单元、集成)的作用和最佳搭配。
  • 在测试驱动开发中,需要结合多种测试类型来建立信心。

掌握这些测试技术和策略,将帮助你构建更可靠、更易维护的Rails应用程序。

014:模型验证、控制器过滤器与数据库关联

在本节课中,我们将学习Rails框架中三个核心概念:模型验证、控制器过滤器以及数据库关联的基础知识。这些工具能帮助我们编写更简洁、更易维护的代码。

模型验证:确保数据完整性

上一节我们介绍了Rails的基本结构,本节中我们来看看如何确保模型数据的正确性。模型验证是一种确保数据在保存到数据库前符合特定规则的方法。通过在模型中定义验证规则,我们可以避免在多个地方重复编写相同的检查逻辑。

以下是Rails中定义验证的几种方式:

  • 基本验证:使用 validates 方法,可以检查属性的存在性、长度、格式等。
    validates :title, presence: true, length: { maximum: 40 }
    validates :release_date, presence: true
    
  • 自定义验证:通过自定义方法实现更复杂的业务逻辑。
    validate :released_1930_or_later
    
    def released_1930_or_later
      errors.add(:release_date, 'must be 1930 or later') if release_date.present? && release_date.year < 1930
    end
    
  • 条件验证:使用 ifunless 选项,只在特定条件下运行验证。
    validates :rating, inclusion: { in: %w[G PG PG-13 R NC-17] }, unless: :grandfathered?
    

当调用 saveupdate 方法时,Rails会自动运行所有定义的验证。如果验证失败,对象不会被保存,并且错误信息会存储在 errors 对象中。

控制器过滤器:处理横切关注点

模型验证处理了数据层面的规则,那么控制器层面的通用逻辑该如何处理呢?这就是控制器过滤器的作用。控制器过滤器允许我们在执行某个控制器动作之前或之后运行指定的代码,非常适合处理如用户认证、日志记录等横切关注点。

以下是控制器过滤器的主要类型和使用方法:

  • 前置过滤器 (before_action):在动作执行前运行,常用于权限检查。
    class SessionsController < ApplicationController
      before_action :set_current_user
    
      def set_current_user
        @current_user = Moviegoer.find_by(id: session[:user_id])
        redirect_to login_path unless @current_user
      end
    end
    
  • 后置过滤器 (after_action):在动作执行后运行,可用于记录日志或清理工作。
  • 限定过滤器范围:使用 onlyexcept 选项,将过滤器限定在特定动作上。
    before_action :require_login, only: [:edit, :update, :destroy]
    

过滤器通过继承在控制器间共享。定义在 ApplicationController 中的过滤器会被所有子控制器继承。

数据库关联基础:建立模型间的关系

在了解了如何保证单个模型的数据质量后,我们需要看看如何定义模型之间的关系。数据库关联是Active Record最强大的功能之一,它允许我们以直观的方式表达如“一部电影拥有多条评论”这样的关系。

关联的基础是外键。外键是表中的一列,它存储了另一张表中某条记录的主键ID,从而建立两者间的引用关系。

例如,在 reviews 表中有一个 movie_id 列,其值指向 movies 表中的某条记录。通过这种机制,我们可以将数据关联起来。

一个简单的SQL查询可以展示如何通过外键连接两张表:

SELECT * FROM movies, reviews WHERE movies.id = reviews.movie_id;

然而,在Rails中,我们通常不需要直接编写这样的SQL。Active Record的关联宏(如 has_manybelongs_to)会为我们处理这些细节。我们将在下一节课深入探讨这些关联宏的用法。

总结

本节课中我们一起学习了Rails中用于保持代码DRY(Don‘t Repeat Yourself)和结构清晰的三个重要工具:

  1. 模型验证:在模型层定义数据规则,确保所有创建和更新操作都遵守一致的约束。
  2. 控制器过滤器:在控制器层处理需要在多个动作前后执行的通用逻辑,如用户认证。
  3. 数据库关联基础:通过外键建立模型间的关系,为下一节学习Active Record关联宏打下基础。

合理运用这些工具,可以极大地提升Rails应用的可维护性和代码质量。

015:Active Record 关联与数据建模设计

在本节课中,我们将继续学习 Active Record 关联,并探讨如何设计应用程序中的数据关系。我们将学习如何利用 Rails 提供的工具来优雅地表达模型间的关联,并理解在项目初期做出明智设计决策的重要性。


课程概述

本节课将深入探讨 Active Record 关联的具体用法,并介绍一些关于如何设计数据关联关系的理论。我们将学习如何利用 has_manybelongs_to 等方法,在代码中轻松地操作数据库关系,从而避免编写复杂的原始 SQL 查询。此外,我们还将了解一些用于理解和可视化数据关系的工具,例如 UML 图和 CRC 卡片。


数据库关系建模回顾

上一节我们讨论了如何在数据库层面表示关系。例如,我们有一个 movies 表和一个 reviews 表,其中 reviews 表通过一个名为 movie_id 的外键与 movies 表关联。这使我们能够将特定的影评与特定的电影联系起来。

然而,仅仅在数据库中建立关系还不够。Active Record 的强大之处在于,它允许我们在 Ruby 代码中以更直观、更便捷的方式操作这些关联。

在 Rails 中定义关联

为了在 Rails 应用中暴露数据库中的关系,我们需要在模型中使用特定的方法。例如,在 Movie 模型中,我们可以声明 has_many :reviews;在 Review 模型中,我们可以声明 belongs_to :movie

代码示例:

# app/models/movie.rb
class Movie < ApplicationRecord
  has_many :reviews
end

# app/models/review.rb
class Review < ApplicationRecord
  belongs_to :movie
end

这些声明并非必需,但它们为我们提供了强大的辅助方法。has_manybelongs_to 是 Rails 提供的方法,它们基于数据库中的外键关系,为我们生成了便捷的查询接口。

关联方法的工作原理

当我们声明 Movie has_many :reviews 后,就可以使用 movie.reviews 方法。Rails 会自动执行一个查询,查找所有 movie_id 等于该电影 ID 的影评,并返回一个由 Review 模型对象组成的集合。

同样,声明 Review belongs_to :movie 后,我们可以使用 review.movie 方法来获取该影评所属的电影对象。

关键点:

  • has_many 返回的是一个集合(通常是复数形式的方法,如 .reviews),你可以对其进行遍历。
  • belongs_to 返回的是单个对象(单数形式的方法,如 .movie)。
  • Rails 能够智能地处理单复数形式。

外键与数据完整性

在数据库迁移中创建关联时,我们使用 t.references 方法来指定外键。

迁移示例:

class CreateReviews < ActiveRecord::Migration[6.0]
  def change
    create_table :reviews do |t|
      t.integer :potatoes
      t.text :comments
      t.references :movie, foreign_key: true
      t.references :moviegoer, foreign_key: true
      t.timestamps
    end
  end
end

外键约束确保了数据的引用完整性。例如,它保证了 reviews 表中的每一个 movie_id 都对应 movies 表中一个真实存在的记录。

理解关联方向:小测验解析

以下是几个帮助巩固概念的小测验及其解析:

场景一: 假设我们在 reviews 表中设置了 movie_id 外键,并且在 Movie 模型中添加了 has_many :reviews,但没有Review 模型中添加 belongs_to :movie。以下哪项是正确的?

  • A. 我们可以调用 movie.reviews
  • B. 我们可以调用 review.movie
  • C. 尝试保存影评时会出现数据库错误
  • D. A 和 B 都正确

解析: 正确答案是 Ahas_many :reviews 提供了从电影到影评的关联方法 movie.reviewsbelongs_to :movie 提供的是反向方法 review.movie。没有它,我们就无法直接通过影评对象获取其关联的电影。只要数据库中有 movie_id 字段,保存影评就不会仅仅因为缺少 Rails 的 belongs_to 声明而报错。

场景二: 在另一个应用中,我们声明了 Professor has_many :courses,但没有声明 Course belongs_to :professor。我们需要在数据库中如何设置?

  • A. 在 professors 表中添加 course_id
  • B. 在 courses 表中添加 professor_id
  • C. 两者都需要
  • D. 两者都不需要

解析: 正确答案是 Bhas_many 关联意味着“一”对“多”,外键应该放在“多”的一方。因此,需要在 courses 表中添加指向 professors 表的 professor_id 外键列。选项 A 将建立的是“一个教授属于一门课程”的关系,这与我们的意图相反。

场景三: 假设数据库中已设置好外键,但未在 Rails 模型中指定 has_manybelongs_to 关联。以下哪些是可能的?

  • A. 使用 movie.reviews
  • B. 使用 review.movie
  • C. A 和 B 都可能
  • D. A 和 B 都不可能

解析: 正确答案是 C。即使不依赖 Active Record 的关联方法,我们仍然可以手动编写 SQL 查询或使用 Active Record 的查询接口(如 Review.where(movie_id: 1))来实现相同的功能。不过,直接使用关联方法要方便得多。

间接关联:has_many :through

有时我们需要通过一个中间模型来获取关联数据。例如,通过 Review 模型,我们可以找到给某部电影写影评的所有用户。

我们可以这样定义:

class Movie < ApplicationRecord
  has_many :reviews
  has_many :moviegoers, through: :reviews
end

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs169-swe/img/0b01a30501fea891eb38872f66d2ae79_15.png)

class Moviegoer < ApplicationRecord
  has_many :reviews
  has_many :movies, through: :reviews
end

class Review < ApplicationRecord
  belongs_to :movie
  belongs_to :moviegoer
end

现在,我们可以使用 movie.moviegoers 来获取所有评论过这部电影的用户,使用 moviegoer.movies 来获取某个用户评论过的所有电影。Rails 会在底层执行 JOIN 查询来组合这些数据。

嵌套路由

当资源间存在从属关系时(例如影评属于电影),在路由中嵌套它们可以生成更有意义且一致的 URL。

路由配置示例 (config/routes.rb):

resources :movies do
  resources :reviews
end

这将生成像 /movies/1/reviews/movies/1/reviews/new 这样的路径。在控制器中,你可以通过 params[:movie_id] 来获取电影 ID,而 params[:id] 则对应影评的 ID。

控制器示例:

class ReviewsController < ApplicationController
  before_action :load_movie

  def create
    @review = @movie.reviews.build(review_params)
    # ... 保存和重定向逻辑
  end

  private

  def load_movie
    @movie = Movie.find(params[:movie_id])
  end
end

使用 before_action 过滤器(如 load_movie)是一种常见的做法,它可以将加载共享资源的逻辑提取出来,保持代码的简洁(DRY)。

可视化数据关系:UML 与 ERD

理解复杂应用中的数据模型可能很困难。统一建模语言(UML) 或更具体地说,实体关系图(ERD),是可视化模型及其关系的强大工具。

在 Rails 中,你可以使用 rails-erd 这个 gem 来自动生成当前应用的 ERD 图。这对于探索遗留代码库特别有用,因为它能快速揭示哪些模型是系统的核心(拥有大量关联)。

设计决策:CRC 卡片

如何决定创建哪些模型和关联呢?类-职责-协作者(CRC)卡片 是一种简单的设计技术。

为每个候选的类(模型)创建一张卡片,写下:

  • 类名: 例如 Order
  • 职责: 这个对象需要做什么?例如,“计算总价”、“知道包含多少张票”。
  • 协作者: 为了完成职责,它需要与哪些其他类交互?例如,TicketShowing

你可以从用户故事或需求描述中提取名词(如 patron, showing, ticket, order)来开始创建这些卡片。这个过程有助于厘清模型间的边界和关系。


课程总结

本节课我们一起深入学习了 Active Record 关联。我们掌握了如何使用 has_manybelongs_to 在 Rails 中便捷地操作一对多关系,并了解了如何通过 has_many :through 建立间接的多对多关联。我们还探讨了如何利用嵌套路由来构建符合 RESTful 风格的 URL 结构。

此外,我们介绍了一些重要的设计工具:用于可视化现有模型的 ERD 图,以及用于在项目初期规划模型的 CRC 卡片。记住,良好的数据模型设计对项目的可维护性和未来的开发体验至关重要。这些技能需要实践来巩固,所以不要害怕在项目中尝试、犯错和调整。

016:团队协作与Git工作流 🛠️

在本节课中,我们将要学习如何在团队环境中进行有效的软件协作。我们将探讨团队动态、冲突解决策略,并深入讲解如何使用Git进行版本控制和团队协作,以确保项目顺利进行。


团队规模与动态 👥

上一节我们介绍了软件工程的基本概念,本节中我们来看看团队协作的重要性。随着软件项目变得越来越复杂,团队规模也在不断增长。从单人开发的《太空侵略者》到数百人协作的《生化危机6》,这说明了在现代软件开发中,团队合作是常态而非例外。

因此,仅仅成为一名优秀的软件工程师是不够的,你还需要学会如何与他人良好合作,并共同管理技术决策。

团队角色与规模

一个高效的团队通常有明确的角色分工。以下是常见的角色:

  • 产品负责人:代表客户利益,负责确定用户故事的优先级。
  • Scrum Master:作为团队的倡导者,负责主持每日站会、回顾会议,并帮助团队扫清障碍。
  • 工程师:负责具体的开发工作。

关于团队规模,一个常见的经验法则是“两个披萨团队”,即团队的规模应该能被两个披萨喂饱(通常指5-8人)。这样的规模便于沟通和协作。

解决团队冲突

当团队中的工程师对技术决策有不同意见时,健康的冲突可以带来更好的软件。以下是几种有效的解决策略:

  • 列出共识:首先,列出所有团队成员都同意的项目目标或事项,建立一个共同的基础。
  • 复述对方观点:当存在分歧时,尝试用自己的话复述对方的论点,以确保你真正理解了对方的立场,这也有助于对方澄清观点。
  • 建设性批评:如果你坚信某个技术决策会对产品产生负面影响,你有责任也有义务提出你的强烈意见。
  • 不同意但承诺执行:当团队做出最终决定后,即使你个人不同意,也应作为一个团队成员承诺执行该决定,以推动项目前进。

Git团队协作工作流 🔀

现在,我们来看看如何利用工具来支持团队协作。Git是一个强大的分布式版本控制系统,而GitHub是基于Git的协作平台。以下是团队协作的核心工作流。

分支策略

在Git中,分支是并行开发的利器。我们推荐使用“功能分支”工作流。

核心工作流公式

master (稳定版)
    ├── feature/login-page (功能分支A)
    ├── feature/user-profile (功能分支B)
    └── hotfix/typo (紧急修复分支C)

基本原则

  1. master 分支:代表项目稳定、可部署的版本。
  2. 功能分支:每个新功能或修复都应从 master 分支创建一个新的功能分支。
  3. 分支命名:建议使用清晰的名字,例如 michael/add-user-authentication

常用命令

# 创建并切换到一个新分支
git checkout -b feature/your-feature-name

# 在该分支上进行开发、提交
git add .
git commit -m "完成用户登录界面"

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs169-swe/img/d55169e6fbcf24ff5e0d52f86f45e399_14.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs169-swe/img/d55169e6fbcf24ff5e0d52f86f45e399_16.png)

# 将分支推送到远程仓库(如GitHub)
git push origin feature/your-feature-name

代码审查与合并

功能开发完成后,需要通过拉取请求(Pull Request, PR)将代码合并回 master 分支。

以下是PR流程的关键步骤:

  1. 在GitHub上为你的功能分支创建PR。
  2. 请求团队成员审查你的代码。
  3. 确保所有自动化测试(如Travis CI)通过,显示为绿色勾选标记。
  4. 在至少获得一名其他团队成员批准(LGTM - Looks Good To Me)后,合并PR。
  5. 合并后,可以将 master 分支部署到生产或 staging 环境。

小贴士:保持PR的规模小巧且专注,只解决一个明确的问题。这样更容易审查,也更容易定位和回滚可能引入的错误。


故障修复流程 🐛

在团队项目中,修复bug是常事。一个结构化的修复流程可以提高效率。

以下是推荐的bug修复步骤:

  1. 报告:在项目跟踪工具(如Pivotal Tracker)中创建bug报告。
  2. 复现与分类:确保可以稳定复现该bug,并更新报告中的步骤。确认它确实是一个需要修复的bug,而不是功能请求或设计限制。
  3. 回归测试:在开发环境中验证bug同样存在。
  4. 修复:遵循TDD(测试驱动开发)原则,先编写一个会失败的测试来暴露这个bug,然后修复代码使测试通过。
  5. 发布:通过PR流程合并修复,并部署到相应环境。

对于需要紧急修复生产环境bug的情况,可能会使用“热修复分支”,并可能用到Git的 cherry-pick 命令将特定提交应用到旧版本分支。


Git实用技巧与注意事项 💡

为了更顺畅地使用Git,这里有一些实用技巧和需要避免的陷阱。

实用技巧

  • 频繁提交:将工作拆分成小块并频繁提交。清晰的提交信息有助于团队理解你的工作内容。
  • 利用别名和配置:可以配置命令行提示符显示当前分支,或设置Git别名来简化常用命令。
  • 使用hub等增强工具hub是Git的扩展工具,可以简化一些与GitHub交互的操作,例如 hub pr checkout <编号> 可以直接拉取同事的PR进行本地测试。
  • 善用git checkout -- <file>:此命令可以丢弃对某个文件的本地修改,将其恢复到最后一次提交的状态。

需要避免的陷阱

  • 直接向master分支提交:永远不要这样做。始终通过功能分支和PR流程来合并代码。
  • 忽略合并冲突:在合并分支前,先使用 git pullgit fetch 获取远程最新更改,减少冲突。解决冲突后务必运行测试。
  • 长期不更新的分支:分支存在时间越长,未来合并时发生冲突的可能性就越大。尽量保持分支短命,并定期从 master 分支合并更新。
  • 提交不完整的代码:确保提交前代码可以编译并通过相关测试。

总结 📚

本节课中我们一起学习了软件工程中的团队协作。我们了解了团队角色、解决冲突的策略,并深入掌握了Git在团队协作中的核心工作流,包括功能分支、代码审查和bug修复流程。记住,有效的沟通、清晰的流程和良好的工具使用习惯,是团队项目成功的关键。不断练习这些技能,你将在未来的软件工程职业生涯中受益匪浅。

017:代码质量与重构 🛠️

在本节课中,我们将学习如何评估和改进代码质量,重点介绍重构的概念、工具和具体技巧。我们将从理解“代码异味”开始,学习如何编写“特征测试”来理解遗留代码,并探索量化代码复杂度的指标。最后,我们将通过一个真实案例,一步步演示如何重构有缺陷的代码。

概述

当我们接手一个遗留项目,面对没有测试、难以理解的代码时,首要任务是确保在修改代码时不会破坏现有功能。本节课将介绍“特征测试”作为解决这一困境的方法,并探讨如何通过重构来提升代码的可读性、可维护性和质量。我们将学习定性的“代码异味”识别和定量的复杂度度量工具。

特征测试:理解未知代码

上一节我们提到了接手遗留项目的挑战。本节中,我们来看看如何通过“特征测试”来建立对现有代码行为的认知基线。

当你面对一个没有测试或测试不充分的应用程序时,直接编写代码是危险的,因为你不知道是否会破坏现有功能。同时,在不理解代码的情况下也无法编写有意义的测试。特征测试就是为了打破这个僵局。

特征测试的核心目标是:编写可重复的自动化测试,以确立应用程序当前行为的“事实”。我们首先手动测试应用,理解它的功能,然后将这些观察转化为自动化测试。

集成级特征测试

对于高层次的应用行为,我们可以使用Cucumber和Capybara等工具。

以下是编写集成级特征测试的步骤:

  1. 从编写非常直接、命令式的步骤开始。
  2. 例如:“访问首页 -> 在邮箱字段输入我的邮箱 -> 在密码字段输入密码 -> 点击登录 -> 被重定向到X页面并看到Y和Z内容”。
  3. 这些步骤最初可能没有捕捉到“应该发生什么”的业务逻辑,但它们是一组可重复的步骤,记录了系统的某些信息。
  4. 一旦我们理解了系统,就可以改进这些场景,使其更具声明性。

模块/单元级特征测试

对于方法级别的行为,我们可以采用一种系统化的“测试-失败-填充”方法。

以下是一个为计算销售税的方法编写特征测试的例子:

  1. 我们有一个Order类,其中有一个compute_tax方法。
  2. 我们首先创建一个模拟的order对象。
  3. 编写测试:expect(order.compute_tax).to eq(-99.9)。选择什么值并不重要,我们只是需要一个占位符。
  4. 运行测试,它可能会失败并提示:object Order received unexpected message get_total
  5. 根据错误信息,我们完善测试,为模拟对象设置get_total方法返回一个值,例如100。
  6. 再次运行测试,可能会得到新的失败信息:expected compute_tax to be -99.9 but was 8.45
  7. 这个8.45很有用!它告诉我们,对于总额100的订单,当前系统的税率是8.45%。
  8. 于是我们将测试期望值更新为8.45,这样就得到了一个捕获当前行为的有效测试用例。

特征测试的目的不是验证代码是否正确,而是确保我们不会意外地改变其现有行为。即使现有行为本身是错误的,测试也能帮助我们记录下这种“错误的状态”。

集成测试与单元测试的特征测试对比

以下是集成级特征测试与单元级特征测试的主要区别:

  • 测试基础:集成测试主要基于模拟用户操作的脚本;单元测试主要基于观察方法级别的行为。
  • 代码结构知识:对于集成测试,详细的代码结构知识重要性较低;对于单元测试则更为重要。
  • 外部依赖:集成测试更可能意外地依赖于生产数据库中的数据或特定状态。

代码质量与“代码异味”

在确保了代码行为不会意外改变之后,我们就可以着手改进代码质量了。代码的正确性永远是第一位的,但“优美”的代码能让我们未来的工作更轻松。

定性评估:SOFA原则

我们可以使用“SOFA”这个缩写来记忆几个定性的代码质量原则(想象一个发霉的旧沙发):

  • 简短:更短的代码通常更易读、更易维护。
  • 单一职责:函数或类应该只做一件事,并把它做好。
  • 参数要少:函数的参数应尽可能少,尤其是避免过多的位置参数。
  • 一致的抽象层次:代码应保持在同一抽象层次上,使逻辑清晰可读。

保持单一抽象层次

复杂的任务应该被分解。一个函数应该将细节委托给其他函数。如果一段代码非常复杂,就应该将其提取到独立的方法中。这样做不仅使代码更易读(通过描述性的方法名),也使提取出的方法更易于独立测试。

警惕过多参数

参数过多的函数难以获得良好的测试覆盖,因为你需要为多种参数组合设置测试。布尔参数尤其是一个“黄色警报”,它通常意味着函数内部有两条独立的执行路径。更好的做法是将它拆分成两个命名清晰的方法。

识别“数据泥团”

如果某些参数总是结伴出现,这可能意味着它们代表了一个共同的概念。考虑将这些数据提取成一个单独的类,这样你只需要传递这个类的实例,从而使代码更易于维护。

定量评估:复杂度指标

除了定性原则,我们还可以使用量化工具来衡量代码复杂度。

ABC评分
ABC代表赋值、分支和条件。其计算公式是:sqrt(A² + B² + C²)。通常建议每个方法的ABC分数应小于或等于20。可以使用flog这个gem来检查ABC复杂度。

圈复杂度
圈复杂度衡量的是代码中线性独立路径的数量。路径越多,代码越复杂,越容易出错。NIST建议每个模块或函数的圈复杂度应小于10。可以使用saikuro这个gem来测量。

其他指标
还包括测试代码与产品代码的行数比(目标通常为1:2或更高)、语句覆盖率(SimpleCov,目标通常为90%以上)等。

工具如Code Climate会综合这些指标,并高亮出代码中的“热点”(最复杂、最可能出问题的部分)。我们的目标是让这些指标数值不断降低。

简短代码的威力:一个例子

以下是一个合并字谜数组的Ruby代码初始版本:

def combine_anagrams(words)
  result = []
  words.each do |word|
    temp = word.downcase.split(//).sort.join
    found = false
    result.each do |res|
      if res[0].downcase.split(//).sort.join == temp
        res << word
        found = true
      end
    end
    result << [word] unless found
  end
  result.uniq
end

使用Ruby的内置惯用法重构后:

def combine_anagrams(words)
  words.group_by { |word| word.downcase.chars.sort }.values
end

重构后的代码不仅更短、更声明式(读起来像在描述要做的事),而且由于利用了语言内置的、经过充分测试的方法,其正确性更有保障,需要测试的内容也更少。使用flog测量,其ABC分数从18.8降到了5.2。

方法级重构实战

上一节我们介绍了评估代码质量的工具。本节中,我们通过一个真实案例来看看如何应用这些原则进行重构。

我们从一段存在“代码异味”的Rails控制器代码开始。这段代码检查当前用户是否退订了邮件,并根据应用设置决定是否显示鼓励重新订阅的提示信息。

初始代码:

def show
  # ... 其他逻辑 ...
  if current_user.email_blacklist? && current_user.valid_email_address? && AppConfig.get(:show_opt_in_message)
    flash.now[:login] = "Please click billing address to update your preferences."
  end
  # ... 其他逻辑 ...
end

这段代码的问题在于:一个条件语句包含了三个条件和一个赋值,混合了不同抽象层次的概念(用户状态、应用配置、UI消息)。

重构过程是逐步进行的:

  1. 提取方法:首先,将核心判断逻辑提取到一个描述性更强的方法中。
    def show
      flash.now[:login] = encourage_opt_in_message if current_user_opted_out_of_email?
      # ...
    end
    
  2. 完善提取的方法:接着,实现这些新方法,每个方法只负责一件事。
    # 在 ApplicationController 或 Helper 中
    def encourage_opt_in_message
      return unless AppConfig.get(:show_opt_in_message)
      "Please click billing address to update your preferences."
    end
    
    # 在 User 模型中
    def opted_out_of_email?
      email_blacklist? && valid_email_address?
    end
    

经过重构,代码的意图变得清晰:如果用户退订了邮件,则显示鼓励订阅的消息。每个方法都简短且职责单一,更易于理解和测试。这个过程中,我们依靠测试来确保每一步重构都没有破坏原有功能。

深度重构案例:Zune播放器日期bug

现在,让我们看一个更复杂的、来自真实世界的重构案例,它涉及一个著名的软件故障:2008年12月31日,所有微软Zune播放器因日期计算代码陷入无限循环而“变砖”。

问题的核心是一个将“自1980年以来的天数”转换为“年月日”的函数。以下是其Ruby直译版本:

def compute_year(days)
  year = 1980
  while days > 365
    if (year % 400 == 0) || (year % 4 == 0 && year % 100 != 0)
      if days > 366
        days -= 366
        year += 1
      end
    else
      days -= 365
      year += 1
    end
  end
  year
end

这段代码充满了“代码异味”:单字母变量名、魔术数字(365, 366)、复杂的嵌套条件逻辑,导致在闰年的最后一天计算错误,days无法减少,陷入无限循环。

我们的重构将分多个小步进行,每一步都保持测试通过(或暴露同样的bug):

  1. 提取闰年判断方法:将复杂的闰年判断逻辑提取成一个独立的方法leap_year?。这立刻让主循环的逻辑清晰了一些。
  2. 引入类来组织数据:创建一个ZuneDate类,将yeardays作为实例变量。添加add_leap_yearadd_regular_year等方法,使主循环读起来更像业务描述:“当剩余天数大于365天时,如果是闰年则减去366天,否则减去365天,同时年份增加”。
  3. 修正逻辑错误:最终,在清晰的结构基础上,我们更容易发现并修复那个导致无限循环的边界条件bug。修复后的循环逻辑应该能正确处理闰年和非闰年的所有情况。

在整个重构过程中,我们可以使用flog来量化代码复杂度的改善。尽管随着提取出更多方法,文件的总复杂度可能上升,但每个独立方法的复杂度(ABC分数)显著下降,这意味着每个小部分都变得更易于理解和维护。

此外,还可以使用rubycritic这样的工具,它结合了reek(代码异味检测)、flog(ABC复杂度)和saikuro(圈复杂度),在本地提供一个类似于Code Climate的代码质量报告,帮助识别重构的优先级。

总结

本节课中,我们一起学习了如何提升代码质量与进行重构。

  • 我们首先介绍了特征测试,这是安全处理遗留代码的基石,它帮助我们在不理解内部实现的情况下,先捕获并锁定当前的行为。
  • 接着,我们学习了定性的代码异味识别原则,即SOFA原则:追求简短、单一职责、参数少和抽象层次一致的代码。
  • 然后,我们探讨了定量评估工具,如ABC评分和圈复杂度,它们为代码改善提供了客观的度量标准。
  • 最后,我们通过两个实战案例,演示了如何运用这些原则和工具,一步步将混乱、复杂的代码重构得清晰、健壮且易于维护。

记住,重构是一个持续的过程,目标不是一步到位写出完美的代码,而是通过一系列可验证的小步骤,让代码朝着更好的方向不断演进。善用你的测试套件和代码质量工具,它们是你重构过程中最可靠的伙伴。

018:软件设计模式与SOLID原则

在本节课中,我们将要学习软件设计模式,特别是SOLID设计原则中的前两项:单一职责原则和开闭原则。我们将探讨如何利用这些模式和原则来构建更易于维护、扩展和理解的代码。


单一职责原则

上一节我们介绍了设计模式的基本概念,本节中我们来看看SOLID原则中的第一个,也是最重要的一个:单一职责原则。

单一职责原则规定,一个类应该只有一个引起它变化的原因。换句话说,一个类应该只负责一件事。这与我们之前讨论的函数级重构原则“只做一件事”一脉相承。

如何识别违反SRP的类

以下是识别一个类是否承担了过多职责的一些迹象:

  • 高内聚性缺失:类中的方法彼此关联性不强,使用了不同的、不相关的实例变量集合。
  • 方法簇:某些方法总是成组出现,并接收相同的参数集合(例如,总是同时传递 street_namehouse_numberzip_code)。这暗示这些参数应该被提取成一个新的类。
  • 冗长的类:类文件过长,包含了处理多个不同领域逻辑的方法。

如何遵循SRP

遵循SRP的核心方法是提取更小的类,直到每个类都只专注于做好一件事。

让我们通过一个具体例子来理解。假设我们有一个 Customer 类,它存储了客户的地址信息,并包含了许多处理地址逻辑的方法(如计算税率、格式化地址等)。这违反了SRP,因为 Customer 类同时处理了“客户”和“地址”两种职责。

解决方案:我们可以创建一个新的 Address 类。

class Address
  attr_reader :customer

  def initialize(customer)
    @customer = customer
  end

  def zip_code
    customer.zip_code
  end

  def street
    customer.street
  end

  # 其他与地址相关的方法,如计算税率、格式化等
end

然后,在 Customer 类中,我们可以使用 Rails 的 delegate 方法将地址相关的属性委托给 Address 实例。

class Customer < ApplicationRecord
  has_one :address

  delegate :zip_code, :street, to: :address
end

这样,Customer 类只负责客户的核心逻辑,而 Address 类专门处理所有与地址相关的逻辑。这使得代码更清晰,单元测试也更易于编写。


开闭原则

在理解了如何让类职责单一之后,我们来看看如何让类更容易扩展。这就是开闭原则。

开闭原则规定:软件实体(类、模块、函数等)应该对扩展开放,但对修改关闭。这意味着,当需要添加新功能时,我们应该通过添加新代码(扩展)来实现,而不是修改已有的、已经工作正常的代码。

违反开闭原则的迹象

一个典型的违反开闭原则的代码特征是使用了 case 语句或一连串的 if-elsif 条件判断,根据不同的类型来执行不同的行为。

例如,一个 Report 类根据格式输出报告:

class Report
  def output_report(format)
    case format
    when :html
      # 生成HTML报告
    when :pdf
      # 生成PDF报告
    # 当需要添加JSON格式时,必须修改这里的case语句
    end
  end
end

每增加一种新的报告格式(如JSON),我们就必须修改 output_report 方法,这违反了“对修改关闭”的原则。

应用设计模式遵循开闭原则

为了解决这个问题,我们可以应用几种设计模式。

1. 抽象工厂模式

我们可以使用元编程动态地根据传入的格式字符串来查找并实例化对应的格式化器类。

class Report
  def output_report(format)
    formatter_class = "#{format.to_s.camelize}Formatter"
    formatter = Object.const_get(formatter_class).new
    formatter.output_report(self)
  rescue NameError
    raise "Unknown report format: #{format}"
  end
end

这样,要添加一个新的 JsonFormatter,我们只需要创建这个新类,而无需修改 Report 类本身。

2. 模板方法模式

当一系列步骤相同,但每个步骤的具体实现不同时,可以使用模板方法模式。我们定义一个抽象基类来规定步骤,子类来实现具体步骤。

class ReportFormatter
  def output_report(report)
    output_start
    output_title(report.title)
    output_body(report.body)
    output_end
  end

  # 这些方法由子类实现
  def output_start; end
  def output_title(title); end
  def output_body(body); end
  def output_end; end
end

class HtmlFormatter < ReportFormatter
  def output_title(title)
    puts "<h1>#{title}</h1>"
  end
  # ... 实现其他方法
end

class PdfFormatter < ReportFormatter
  def output_title(title)
    # 使用PDF生成库的代码
  end
  # ... 实现其他方法
end

3. 策略模式

策略模式比模板方法模式更灵活。它将可互换的算法或行为封装成独立的类。Report 类不关心具体使用哪个格式化器,它只依赖一个通用的 Formatter 接口。

class Report
  attr_reader :title, :body
  attr_accessor :formatter

  def initialize(formatter)
    @title = 'Monthly Report'
    @body = ['Things', 'are', 'going', 'well']
    @formatter = formatter
  end

  def output_report
    formatter.output_report(self)
  end
end

class HtmlFormatter
  def output_report(context)
    # 输出HTML
  end
end

class PdfFormatter
  def output_report(context)
    # 输出PDF
  end
end

# 使用方式
report = Report.new(HtmlFormatter.new)
report.output_report

4. 装饰器模式

装饰器模式允许我们通过包装(装饰)对象来动态地添加新功能,而不是通过继承创建大量子类。这在需要组合多种特性时特别有用。

例如,一个基础的 PdfFormatter,我们可以用 PdfWithPasswordFormatter 来装饰它,为其添加密码功能,再用 PdfWithWatermarkFormatter 来装饰前者,添加水印功能。这样就得到了一个同时具有密码和水印的PDF格式化器,而无需创建 PdfWithPasswordAndWatermarkFormatter 这样的具体子类。

class PdfFormatter
  def output
    # 生成基础PDF
  end
end

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs169-swe/img/3be1d71e56856fc469366fb9d362137b_10.png)

class PdfWithPasswordFormatter
  def initialize(pdf_formatter)
    @pdf_formatter = pdf_formatter
  end

  def output
    pdf = @pdf_formatter.output
    add_password(pdf)
    pdf
  end

  def add_password(pdf)
    # 添加密码逻辑
  end
end

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs169-swe/img/3be1d71e56856fc469366fb9d362137b_12.png)

# 组合使用
basic_pdf = PdfFormatter.new
secure_pdf = PdfWithPasswordFormatter.new(basic_pdf)
secure_pdf.output # 输出带密码的PDF

Rails 中的作用域是装饰器模式的一个绝佳例子。你可以链式调用多个作用域来组合复杂的查询,而无需为每种组合都定义一个单独的方法。


高级Cucumber/Capybara技巧

在掌握了核心设计原则后,让我们看一些能提升测试效率的实用工具。

以下是几个有用的技巧:

  • launchy Gem:这个gem可以在运行Cucumber步骤时自动打开浏览器,让你实时观察测试执行过程,对于调试非常有用。它提供了 save_and_open_page 方法。
  • Capybara Cheat Sheet:网上有很好的Capybara命令速查表,Piazza上也有链接,可以帮助你快速查找定位元素、填写表单等方法。
  • page 对象:在Capybara中,page 对象是你与浏览器交互的主要接口。你可以通过它执行自定义的JavaScript或CSS,这在测试复杂的前端交互时很有用。
  • 处理表格数据:Cucumber提供了处理表格数据的便捷语法,可以让你在场景中直接使用结构化的数据。
  • 步骤间传递状态:使用实例变量可以在不同的Cucumber步骤之间共享状态。
  • 使用 Timecop:如果你的应用与日期时间相关(如截止日期、上映时间),Timecop gem可以“冻结”或“旅行”到特定时间,使时间相关的测试变得简单可靠。
  • 使用标签:可以为不同的场景打上标签,以便只运行特定的测试集。

总结

本节课中我们一起学习了软件设计的基础——SOLID原则的前两项。我们深入探讨了单一职责原则,它要求一个类只做一件事,并通过提取小类来达成这一目标。接着,我们学习了开闭原则,它要求代码应对扩展开放,对修改关闭,并介绍了抽象工厂模板方法策略装饰器等设计模式来帮助我们实现这一原则。最后,我们了解了一些能提升端到端测试效率的Cucumber和Capybara高级技巧。掌握这些原则和模式,将帮助你构建出更健壮、更灵活、更易于维护的软件系统。

019:SOLID设计原则与常用模式

在本节课中,我们将继续学习SOLID设计原则,重点探讨里氏替换原则(L)和依赖注入(I/D),并介绍几种在Rails开发中常用的设计模式,如适配器、外观、空对象、代理和组合模式。这些原则和模式旨在帮助我们编写更清晰、更易维护、耦合度更低的代码。

微测验与项目评分更新

上一节我们介绍了课程的基本安排,本节中我们来看看关于微测验和项目迭代评分的一些具体更新。

关于微测验,未来几周将再有五次测验。由于期中考试,原定今天的测验将推迟到下周。为了凑齐总共10次测验,其中某一周将进行两次微测验。微测验的正确性比课堂点击器问题更重要,但只要完成测验,通常就能获得大部分分数。平均正确率在一半左右即可获得满分。未来的测验题目会稍长一些,大约包含5道题。

关于项目评分,在迭代一阶段,我们对各项自动化工具的设置要求较为宽松。但从迭代二开始,以下自动化工具和部署将成为评分的必要组成部分:

  • Travis CI:持续集成。
  • Code Climate:代码质量分析。
  • Heroku部署:一个可访问的线上应用。

如果Heroku应用没有部署,客户将无法查看项目进展,这会是一个严重问题。Travis和Code Climate相对容易与GitHub集成,如有问题,请在Piazza或团队Slack频道中提问。

团队Slack频道中的“站立会议伙伴”机器人已更新。团队每周需要进行至少一次全员参与的站立会议,更频繁的会议记录将获得加分。由于学生日程不同步,允许某次站立会议只有部分成员参加,但必须保证至少一次会议全员参与。站立会议是记录进展、讨论挑战(如Active Record查询问题、测试不稳定性等)的好工具。

里氏替换原则 (Liskov Substitution Principle)

在介绍了SOLID中的“开闭原则”后,本节我们来看看“里氏替换原则”。

Barbara Liskov是一位杰出的计算机科学家,她因在数据抽象和面向对象编程方面的贡献获得了图灵奖。里氏替换原则看似简单,却意义深远。

核心概念:一个适用于类型T实例的方法,必须同样适用于T的任何子类型。换言之,在程序中,一个父类实例应该能够被其任何子类实例替换,而程序的行为和语义保持不变。

原则详解与违反示例

为什么这个原则重要?我们通过一个经典例子来说明。在几何中,正方形是一种特殊的矩形。因此,在面向对象设计中,很自然地会让Square类继承Rectangle类。

class Rectangle
  attr_accessor :width, :height

  def make_tall_and_skinny
    self.width = 1
    self.height = 10
  end
end

class Square < Rectangle
  # 正方形无法变得“高而瘦”,因为它的宽高必须相等。
  # 如果继承了这个方法,要么它无效,要么会破坏正方形的定义。
end

这里,Rectangle有一个make_tall_and_skinny方法。Square作为子类,虽然继承了该方法,但该方法对Square对象没有意义(正方形不能改变为高瘦形状)。这就违反了里氏替换原则,因为子类无法完全替换父类并保持所有行为的语义一致性。

解决方案:使用组合替代继承

过度使用继承会导致设计混乱。里氏替换原则提示我们,有时使用组合比继承更合适。

以下是两种设计方式的UML对比:

  • 继承方式Square -> RectangleSquare被迫拥有无意义的make_tall_and_skinny方法。
  • 组合方式Square 包含一个 Rectangle 实例作为属性,并将相关方法委托给该实例。

在Ruby中,可以使用delegate方法来实现组合:

class Square
  attr_reader :rectangle

  def initialize(side_length)
    @rectangle = Rectangle.new(side_length, side_length)
  end

  # 将 area 方法委托给内部的 rectangle 对象
  delegate :area, to: :rectangle

  # 正方形特有的方法...
  def side_length
    rectangle.width
  end
end

这样,Square不再继承Rectangle,而是通过包含一个Rectangle实例来复用其逻辑,同时避免了语义上的矛盾。

关于静态类型语言的思考

有一个常见的误解:在静态类型语言(如Java)中,只要编译器不报错,就没有里氏替换原则的违反。这是错误的

编译器检查的是语法和类型签名,而里氏替换原则关注的是行为的语义。即使子类用抛出错误的方式“实现”了父类的方法(例如Square#make_tall_and_skinny抛出NotImplementedError),编译器也会通过,但这明显违反了该原则,因为子类对象无法真正替代父类对象工作。

“拒绝继承” 是违反该原则的一个信号,即子类需要大量重写或禁用父类方法,这表明这两个类可能并不适合构成继承关系,应考虑重构。

依赖注入与适配器模式 (Dependency Injection & Adapter)

上一节我们探讨了通过组合来遵循里氏替换原则,本节中我们来看看另一种强大的设计技巧:依赖注入,以及与之密切相关的适配器模式。

在SOLID中,“D”通常指依赖倒置原则。在本课程中,我们将其作为“I”(注入)来讨论,因为“接口隔离原则”在Rails中应用相对较少。

什么是依赖注入?

核心概念:类A依赖类B的接口,但B的具体实现可以变化和替换。A不直接创建B的实例,而是由外部“注入”给它。

这增加了灵活性,使得在不修改A代码的情况下,就能更换A所依赖的B的实现。

Rails中的实例:会话存储

Rails应用中的会话存储是一个典型例子。应用需要存储会话信息,但有多种后端可选:

  • CookieStore:将会话数据存储在客户端Cookie中。
  • ActiveRecordStore:使用数据库表存储会话。
  • RedisStore:使用Redis内存数据库存储。

Rails的会话存储机制允许你“注入”不同的存储适配器。你的应用代码(依赖于“会话存储”这个接口)无需关心底层是Cookie、数据库还是Redis。

适配器模式 (Adapter Pattern)

依赖注入常常通过适配器模式实现。适配器就像一个转换头,它让一个类的接口转换成客户端期望的另一种接口,从而使原本不兼容的类可以一起工作。

UML示意

Client -> Target (接口)
              ^
              |
        Adapter (实现Target接口,内部包装了Adaptee)
              |
        Adaptee (需要被适配的类,如MySQL数据库驱动)

Rails中的经典案例:Active Record适配器。Active Record是一个ORM框架,它通过不同的适配器来支持MySQL、PostgreSQL、SQLite等数据库。你的业务代码使用统一的Active Record接口(如where, order),适配器负责将其翻译成特定数据库的SQL方言。更换数据库时,你只需更换适配器配置,而无需重写业务逻辑。

其他例子

  • 邮件发送:应用需要支持Mailchimp、Constant Contact等不同的邮件服务。可以定义一个EmailList接口,然后为每个服务创建适配器(MailchimpListAdapter, ConstantContactAdapter)。
  • 聊天机器人:一个机器人逻辑可能需要适配Slack、Microsoft Teams、Discord等不同聊天平台。

外观模式 (Facade Pattern)

外观模式与适配器模式非常相似,但侧重点略有不同。

核心区别:适配器主要解决接口不兼容的问题。外观模式则旨在为复杂的子系统提供一个简化、统一的接口,它可能只暴露子系统功能的一个子集,隐藏不必要的复杂性。

一个对象可以同时是适配器和外观。例如,你的邮件列表外观可能只提供subscribeunsubscribesend_campaign三个简单方法,而背后复杂的邮件服务API(如管理联系人列表、设计模板、查看报告等)都被隐藏起来。

空对象模式与代理模式 (Null Object & Proxy)

在学习了通过适配器和外观来管理依赖之后,我们来看看另外两种用于简化逻辑和封装行为的模式。

空对象模式 (Null Object Pattern)

核心概念:提供一个行为合理的默认对象,用来替代nil值,从而避免代码中遍布nil检查。

经典场景:用户登录状态。许多地方需要访问current_user。当用户未登录时,current_usernil,调用其方法(如current_user.name)会引发错误。

解决方案:引入一个NullUserGuestUser类。

class NullUser
  def logged_in?
    false
  end

  def name
    "Anonymous"
  end

  def vip?
    false
  end

  # 其他属性返回合理的默认值...
end

这样,在控制器或视图中,你可以安全地调用current_user.name,而无需事先检查if current_user。代码更简洁,语义更清晰。

单例实现:通常,整个应用只需要一个空对象实例。在Ruby中,可以使用类变量或模块来实现单例。

class Customer
  def self.null_customer
    @null_customer ||= NullCustomer.new
  end
end

class NullCustomer < Customer
  # 重写方法,提供默认行为
  def name
    "Anonymous"
  end
  # ... 其他方法
end

代理模式 (Proxy Pattern)

核心概念:为另一个对象提供一个代理或占位符,以控制对这个对象的访问。代理可以在调用实际对象之前或之后插入额外逻辑。

常见用途

  1. 延迟加载:直到真正需要数据时才执行昂贵操作。
  2. 访问控制:在访问前检查权限(如认证)。
  3. 远程代理:代表一个存在于远程位置的对象。
  4. 日志记录:记录方法的调用。

Rails中的实例:Active Record关联和查询是代理模式的绝佳例子。

@movies = Movie.where(genre: 'Action') # 此时并未执行SQL查询
@movies = @movies.where('rating > ?', 8) # 继续构建查询,仍未执行
@movies.each { |m| puts m.title } # 直到这里,才真正执行SQL查询并获取数据

@movies在迭代之前是一个代理对象(ActiveRecord::Relation),它封装了查询逻辑,并延迟到最后一刻才执行。这允许我们高效地链式调用查询方法。

测试中的应用:使用FakeWebVCRWebMock等gem来拦截测试中的外部HTTP请求,并返回预设的响应。这就是一个代理模式——它拦截了对真实网络服务的调用,代之以一个可控的、离线的响应,使测试更快、更稳定、不依赖网络。

组合模式与迪米特法则 (Composite Pattern & Law of Demeter)

最后,我们来看两种用于处理对象结构和对象间通信的模式与原则。

组合模式 (Composite Pattern)

核心概念:将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得客户端对单个对象和组合对象的使用具有一致性。

实例:售票系统。有RegularTicket(普通票),VIPTicket(VIP票),还有Subscription(订阅,包含多张票)。

如何建模?让Subscription继承Ticket似乎有点奇怪,但它确实是一种可购买、有价格的“票务产品”。

解决方案:使用组合模式。

  • Ticket是组件基类。
  • RegularTicketVIPTicket是叶子节点。
  • Subscription(或MultiTicket)是组合节点。它继承自Ticket,但内部维护一个tickets列表。
class Subscription < Ticket
  def initialize
    @tickets = []
  end

  def add_ticket(ticket)
    @tickets << ticket
  end

  def price
    # 订阅价格可能是所含票务总价的折扣
    @tickets.sum(&:price) * 0.9
  end

  def add_to_order(order)
    @tickets.each { |t| t.add_to_order(order) }
  end
end

这样,Subscription可以像普通Ticket一样被处理(例如添加到购物车计算总价),同时又管理着内部的票务集合。

Rails工具:单表继承:Rails的STI功能非常适合实现这种模式。在tickets表中有一个type字段,其值可以是RegularTicketVIPTicketSubscription。当Rails从数据库加载记录时,会根据type字段自动实例化正确的类。这允许共享数据表,同时保持不同的行为。

迪米特法则 (Law of Demeter)

核心概念:又称“最少知识原则”。一个对象应该只与其“朋友”(直接关联的对象)交谈,而不与“陌生人”(朋友的朋友)交谈。简单说:只调用属于以下范围的方法

  1. 对象自身的方法。
  2. 该对象属性(或实例变量)的方法。
  3. 方法参数的方法。
  4. 在方法内部创建的对象的方法。

违反示例

# 报童类需要顾客支付
class PaperBoy
  def collect_payment(customer, amount)
    # 违反迪米特法则:深入了解了customer.wallet.cash的细节
    if customer.wallet.cash >= amount
      customer.wallet.cash -= amount
      # 收钱...
    end
  end
end

这段代码的问题在于,PaperBoy不仅知道Customerwallet,还知道walletcash属性,并且可以直接操作它。如果Wallet的内部结构改变(例如现金改为balance属性),PaperBoy类也需要修改。

重构方案

  1. 委托方法:在Customer上创建一个简便方法。

    class Customer
      def cash
        wallet.cash
      end
    end
    # PaperBoy中使用:customer.cash
    

    这有所改善,但PaperBoy仍然在查询现金数额并自己决定如何扣款。

  2. 封装意图(最佳):让Customer自己处理支付逻辑。

    class Customer
      def pay(amount)
        wallet.withdraw(amount)
      end
    end
    
    class PaperBoy
      def collect_payment(customer, amount)
        customer.pay(amount)
        # 记录支付成功...
      end
    end
    

    现在,PaperBoy只告诉Customer“请支付这个金额”,完全不知道支付如何完成。这极大地降低了耦合度,遵循了迪米特法则。

实际考量:像order.customer.name这样的调用非常常见。严格来说它违反了迪米特法则(order.customer返回一个对象,然后调用其.name)。是否一定要重构?对于像name这样稳定且语义清晰的属性,有时可以容忍。但为其创建一个order.customer_name的委托方法,是一个良好的实践,为未来的变化预留了空间。

总结

本节课中我们一起学习了SOLID原则中的里氏替换原则和依赖注入思想,并深入探讨了五种在实战中极其有用的设计模式:

  • 适配器模式:转换接口,使不兼容的类能够协作。
  • 外观模式:简化复杂子系统的接口。
  • 空对象模式:用提供默认行为的对象替代nil,消除空值检查。
  • 代理模式:控制对对象的访问,增加额外逻辑层(如延迟加载、访问控制)。
  • 组合模式:统一处理单个对象和对象组合,构建树形结构。

同时,我们理解了迪米特法则,它指导我们限制对象之间的知识,减少耦合,让每个对象专注于自己的职责。

掌握这些原则和模式,并非要死记硬背,而是要在设计和重构代码时,拥有一套识别问题并应用合适解决方案的工具集,从而编写出更健壮、更灵活、更易维护的软件。

020:设计模式回顾与DevOps简介 🚀

在本节课中,我们将要学习SOLID设计原则的回顾,并初步了解DevOps(开发运维)的概念,包括持续集成和部署的重要性。

设计模式与SOLID原则回顾

上一节我们结束了设计模式的讨论,本节中我们来回顾一下核心的SOLID原则及其相关模式。

单一职责原则 (Single Responsibility Principle)

单一职责原则规定,一个类应该只有一个引起它变化的原因,即只承担一种责任。

以下是可能违反该原则的症状:

  • 低内聚方法:类中的许多方法不使用该类的实例变量,或者它们访问一组相关的概念,但这些方法不被其他部分使用。
  • 方法参数组:许多方法接收相同的三四个参数,这表明这些参数可能应该属于它们自己的类。

建议的解决方案是提取一个新类。在Rails中,类不一定需要数据库模型支持,你可以创建纯粹的Ruby类,例如AddressSettings类。

开闭原则 (Open/Closed Principle)

开闭原则规定,软件实体应该对扩展开放,但对修改关闭。

症状包括需要向case语句添加新条件。相关的设计模式有:

  • 抽象工厂模式:用于创建相关对象族。
  • 模板方法或策略模式:用于一系列相同步骤,不同用例可进行适配。
  • 装饰器模式:传入一个对象并修改其行为。

里氏替换原则 (Liskov Substitution Principle)

里氏替换原则规定,程序中任何父类对象都可以用其子类对象替换,而程序的行为保持不变。

一个症状是“拒绝请求”,即子类中的方法抛出错误,表示无法响应该方法。这表明这两个类可能不应该处于父子类关系中。解决方案通常是使用组合(例如,Square类包含一个Rectangle实例作为属性)或委托,而非继承。

接口隔离原则 (Interface Segregation Principle)

接口隔离原则规定,不应该强迫客户端依赖它们不使用的接口。

一个症状是违反了开闭原则。解决方案是依赖注入,即让类依赖于一个被注入的公共接口,而不是具体的实现。抽象类是遵循此原则的良好模式。

迪米特法则 (Law of Demeter)

迪米特法则规定,一个对象应该只与其“朋友”(直接关联的对象)交谈,而不与“陌生人”交谈。

症状是出现长的方法调用链,例如customer.name.first。需要注意的是,在数组上使用mapfilter等内置操作不违反此法则。

以下是解决长调用链的方法:

  • 委托方法
  • 提取小类
  • 观察者模式:对象响应事件变化(Rails缓存中使用)。
  • 访问者模式:分离数据与操作。

其他提及的设计模式

除了SOLID原则中涵盖的模式,我们还提到了:

  • 单例模式:确保一个类只有一个实例(例如,Rails中的Inflector用于词形变化)。
  • 命令模式:封装操作(例如,Active Record迁移中的updown方法)。
  • 迭代器模式:遍历集合(在Ruby和Rails中无处不在)。

关于SOLID的注意事项

这些原则最初是为Java/C++等静态类型语言设计的,旨在使重构更容易。在Ruby这样的动态语言中,虽然编译器限制不存在,但遵循这些原则仍能使代码更清晰、更易于维护。关键在于运用判断力,不要为了应用模式而应用模式,而是在识别出问题时使用它们。

从开发到部署:DevOps简介

上一节我们回顾了设计模式,本节中我们来看看如何将代码从开发环境安全可靠地部署到生产环境,这就是DevOps的范畴。

什么是DevOps?

DevOps涵盖了一系列实践和工具,旨在:

  • 使开发者的工作更轻松、更有条理。
  • 帮助安全、可靠地部署代码。
  • 监控应用程序在生产环境中的运行状态。

开发环境 vs. 生产环境

“在我机器上能运行”不是一个可接受的答案。生产环境与开发环境存在诸多差异:

  • 操作系统不同:可能从macOS变为Linux。
  • 用户行为不可预测:真实用户会以意想不到的方式使用应用。
  • 压力条件:并发问题可能在高压下才出现。
  • 配置差异:数据库连接、环境变量等。
  • 安全威胁:存在恶意攻击者。
  • 数据库差异:本地常用SQLite,生产环境常用PostgreSQL。

平台即服务 (PaaS) 的优势

使用像Heroku这样的PaaS可以极大简化运维工作:

  • 免去基础设施管理:无需自己配置服务器、安装软件、打补丁。
  • 轻松扩展:通常只需一个命令即可增加计算资源或实例数量。
  • 标准化环境:减少了“环境差异”导致的问题。

技术选型建议:UNFAT

在选择工具和技术时,可以遵循UNFAT原则:

  1. 理解问题:从问题域出发,而非解决方案域。
  2. 列举选项:研究可能的解决方案。
  3. 阅读文档:阅读技术文档,而非营销材料。
  4. 考虑历史背景:了解该技术为解决何种特定问题而生。
  5. 思考差异与未来:权衡优缺点,考虑未来变化。

性能与安全的核心关注点

在DevOps中,我们主要关注:

性能方面

  • 可用性与正常运行时间:如何定义和测量应用可用性?
  • 响应速度:如何客观测量应用性能?
  • 可扩展性:如何确保应用能随用户增长而平稳扩展?

安全方面

  • 隐私与数据保护:如何实施访问控制并最小化数据存储?
  • 身份验证:如何安全管理用户登录?
  • 数据完整性:如何确保存储的数据安全可靠?

频繁部署与持续集成

现代软件公司每天会进行多次部署,这有助于降低每次变更的风险。实现频繁部署的关键是自动化

持续集成是DevOps的核心实践之一,例如使用Travis CI:

  • 自动化测试:每次代码更新后,在接近生产的环境中运行全套测试。
  • 环境一致性:最小化环境差异。
  • 集成测试:安全地测试与第三方服务的集成。
  • 压力测试:模拟高并发请求。

总结

本节课中我们一起学习了SOLID设计原则的回顾,了解了每个原则的核心思想、违反症状及解决方案。接着,我们引入了DevOps的概念,探讨了开发与生产环境的差异,以及如何通过PaaS、持续集成和自动化来实现安全、频繁的部署,为构建健壮、可维护的软件系统奠定了基础。

021:DevOps与安全

在本节课中,我们将要学习DevOps实践中的持续集成与部署,以及Web应用安全的核心概念。我们将探讨如何自动化测试和部署流程,并了解如何保护应用免受常见攻击。

持续集成与持续部署

上一节我们介绍了DevOps的基本概念,本节中我们来看看持续集成和持续部署的具体实践。

持续集成解决了开发中的许多潜在挑战。其核心思想是提供一个远程托管的环境,用于执行各种在本地开发机器上可能繁琐或耗时的任务。虽然许多测试理论上可以在本地进行,但它们通常很慢。持续集成的优势在于,所有这些任务都可以自动运行,并将结果报告回GitHub。

假设你已经设置了Travis CI,当你创建一个拉取请求时,它会运行你定义的一系列测试(例如Cucumber场景、RSpec测试等)。这些测试在.travis.yml文件中配置,你可以根据需要添加任意多的任务。CI运行所有这些任务并返回结果,它还可以连接到其他自动化流程。

一个极端的例子是Salesforce。几年前,他们就在每次应用构建时运行超过15万个测试用例。Salesforce同时也是一个开发平台,开发者可以构建运行在Salesforce之上的应用。当开发者提交这些应用时,他们必须同时提交测试用例。因此,Salesforce的每次构建不仅运行其内部数万个测试用例,还会运行客户提交的数万个外部测试用例,以确保客户应用不会以意外的方式崩溃。这在开发机器上是不切实际的。对于如此庞大的应用,即使在单个开发者的机器上构建Java应用本身也是不现实的。因此,他们需要先进工具的支持。

持续集成非常有用,建议在未来的每个项目中都进行设置。

持续集成通常与持续部署紧密相关。持续集成是在每次构建时运行我们的测试规范,而持续部署则是在每次构建时部署我们的软件。是否采用100%的持续部署策略,取决于产品的目标、工具以及团队的工作方式。

其理念是,如果Travis CI显示构建成功(绿色),那么主分支就准备就绪。如果你的测试规范良好且通过,为什么不自动部署呢?对于大多数情况,特别是SaaS应用,这很有效。用户加载网页时没有版本概念,也不知道是否有更新,他们可能只是看到新内容。一般来说,这对许多应用都运行良好。

如果你在维护一个为其他开发者提供依赖的库,可能仍然需要特定的发布标签,以便人们知道存在什么版本。许多gem(尤其是JavaScript领域的node模块)会使用机器人将Git提交信息打包成漂亮的发布说明,创建Git标签以跟踪发布,并将版本推送到RubyGems或npm。

持续部署是持续集成的进阶,但并不总是适用于每个项目。如果你的拉取请求合并频率不高,手动部署也是完全可以接受的。

测试策略

以下是测试Ajax功能时可能需要考虑的测试环境:

  • 开发环境:开发新功能时,我们总希望在开发环境中进行测试。如果你不自己测试正在开发的功能,通常就没有很好地完成工程师的工作。不过,对于可以通过规范捕获的单行更改,可能不需要启动整个开发环境。
  • 持续集成服务器:我们应该有像Travis这样的持续集成服务器来运行我们的规范。例如,应该有一些控制器规范来确保某个动作返回正确的JSON数据。
  • Cucumber场景:应该有一些Cucumber场景来确保前端能够正确发出JSON请求并相应地更新UI。
  • 预发布环境:并非每个应用都足够大,需要专门的预发布环境。在本课程中,你的Heroku部署目前本质上就是预发布环境。这是一个非常接近生产环境设置的预发布环境,特别是对于可能调用远程端点或具有复杂依赖性的功能,预发布环境测试变得更加关键。

安全部署与数据迁移

现在,让我们看看如何以安全的方式开发应用,尤其是在进行升级或添加新功能时。

最简单的方式是在可以接受的情况下采用。有些应用可以接受短时间离线,有些业务应用可能只在工作日使用,甚至可以在整个周末离线进行更新。然而,一旦应用变得足够大,拥有多个时区的用户,就很难找到完全不影响使用的更新时间点。监控使用情况可以帮助你找到合适的更新时间。

天真的方法是直接让应用离线,应用数据迁移,更新数据模型,然后部署新代码。这一切以同步方式进行,期间没有用户使用我们的应用。

在代码中,我们可能有一个Active Record迁移,例如从单个姓名字段拆分为名和姓。我们会告诉Active Record添加两个列,然后运行一个脚本遍历数据库更新每个用户的属性。如果数据量很大,这需要一些时间。同时,我们必须更新代码以了解用户模型上现在有名和姓字段。如果我们删除了姓名列,还必须确保应用中没有其他地方引用它。这就是为什么我们希望一步完成。虽然这使事情变得简单,但应用可能需要离线才能做到。

在进行数据迁移时,可以将一系列查询包装在事务中。事务是一个工具,允许你执行一系列工作(可以是一个查询,也可以是一百万个查询),如果其中一部分失败,它可以回滚所有先前的更新。事务保持状态同步,这非常有用,因为如果在迁移数据过程中发现错误,你通常不希望一半用户有名和姓,而另一半用户只有全名字段,除非你构建应用时就打算如此。事务不仅在迁移中有用,在任何需要多个更新在逻辑上保持一致的情况下都可以使用。

如果我们不能离线应用然后部署,如何进行需要模式更改的增量升级呢?这就是设计应用以同时处理多种状态的思路。我们将设计代码来处理路径A和路径B,并使用功能开关来实现。

其理念是首先应用非破坏性迁移。例如,我们先添加数据库列,但此时的代码可能还不会引用它们。然后,我们部署第二个版本,该版本理解新列的工作原理。接着,我们打开一个功能开关。之后,我们以较慢但安全的方式迁移所有数据。当所有数据都迁移完毕后,我们再次部署,移除旧代码。这个过程分为几个步骤。

第一个迁移现在会更复杂一些。我们不仅要添加名和姓字段,可能还会添加一个表示数据是否已迁移的列。虽然技术上可以通过检查两个值是否为nil来避免添加此列,但拥有一个明确的迁移列会使事情更清晰。

然后,在试图将数据从单个姓名迁移到名和姓的模型中,我们将编写一些代码来处理更新用户的两种情况。我们将使用应用级别的功能开关。功能开关是我们构建的模型,并非内置功能,有多种实现方式。如果功能开关已启用(即已完成迁移),我们将通过名和姓查找用户;否则,通过全名查找。这样我们就有了一个处理两种代码路径的方法。

另一半的工作是确保所有数据都处于两种状态,并且我们有知道如何处理这两种状态的代码。这就是为什么遵守德米特法则很重要,它意味着函数不应深入探究对象内部。这样,我们就可以编写一个函数来处理多个地方的行为,而不必更新应用中的每一个视图。

假设我们已经做到了这一点,接下来需要迁移数据。一种迁移数据的方式是,每当由于某种原因更新用户时,在运行时按需进行迁移。这可以是一个before_save钩子。例如,每次用户登录时,我们可能会更新他们的最后登录时间戳,同时自动在数据库中更新他们的姓名。用户无需做任何事。这本质上是说:我将迁移此用户的模式,并标记他们已被迁移,以便在之前的代码中知道该用户已迁移到新版本。

带有功能开关的代码,用于处理多种姓名列的情况,理论上可以在你的应用中永久存在。但随着代码变化,你可能会希望回头清理。

功能开关的理念是,对于一个独特的功能,我们将有两条代码路径。有时更新会更棘手,其中最棘手的例子之一是更新密码哈希算法。

我们可能希望更改数据库中密码的加密方案以增强安全性。挑战在于,密码是单向哈希值。我们可以验证给定密码是否与其哈希值匹配,但不能简单地将加密的密码列迁移到不同的哈希算法而不破坏所有用户的密码。我们自然不希望破坏用户的密码。

我们分多个步骤进行。首先,只添加新的内容。例如,为OmniAuth添加一个新列,或者添加一个表示密码版本的布尔属性。然后,当每个用户登录时,他们提供即将被哈希处理的密码。对于密码,你可以哈希两次:第一次哈希以确保验证通过(符合旧版本),然后用新方案第二次哈希,以便存储更安全的新版本哈希值。最终,根据用户的活跃程度,可能永远不会,所有用户都将迁移到新的密码哈希格式。在某些时候,你可以决定:已经三年没登录了,我将使你的密码失效。

密码是让一切正常工作的一个特别棘手的案例,但如果你关注应用安全新闻,会经常遇到。过去发生过一些数据泄露事件,泄露的数据中有时包含密码版本标识符。这是因为这些公司在某个时间点将密码哈希迁移到了更安全的版本,因此泄露的数据中只有未更新的旧版本密码。这提醒我们,公司确实会被黑客攻击,保护密码安全非常重要。这也是功能开关的另一个重要用途。

功能开关还有其他重要用途。有时我们不是在讨论数据迁移,而只是推出一个大的新功能。这可能是功能的逐步推出,用于性能测试,或者A/B测试。也可能是Beta测试,如果你要推出一个复杂功能,但希望一小部分用户提前访问,功能开关是一种方法。功能开关可能只涉及将代码路径放在某些条件语句后面,在准备就绪之前永远不会执行。这在GradeScope的前端代码中经常发生。

有一个非常有用的gem叫做rollout,它可以帮助你进行数据建模,并提供一些很好的实用程序来检查功能开关是否启用,支持应用级别和每用户级别的控制。

功能开关的存储位置

那么,功能开关应该存储在哪里呢?以下是一些可能的位置:

  • 现有数据库表的列上:例如,用户表上的密码版本标志,或表示是否拥有Beta功能的列。
  • 单独的数据库表:你可能在实际应用中同时拥有这两种方式,功能开关可能分布在多个表中,这取决于它们控制的内容。

将功能开关信息存储在数据库中有两个重要优势。首先,如果功能可以非常快速地切换,将其放在数据库中意味着我们不需要重新部署应用。对于可能影响性能或存在风险的新功能,能够快速切换至关重要。其次,如果是每用户的功能,那么用代码跟踪用户是否有权访问该功能并不是一个好的解决方案。

一般来说,我们的目标应该是让应用功能在数据库中工作。

应用安全

接下来,我们将讨论应用安全。作为应用开发者,我们有责任以保护用户数据安全的方式开发应用代码。我们选择的基础设施也有其自身的安全责任。

我们将讨论一些常见攻击及其缓解措施。CS 161计算机安全课程会深入探讨更多内容,包括密码学、操作系统安全等。今天我们将讨论窃听和SSL、会话劫持、SQL注入、跨站请求伪造、跨站脚本以及敏感属性的大规模赋值。

SSL是HTTPS中的“S”。当我们从计算机向服务器发送请求时,服务器通过HTTP发回一堆数据。这些数据可能包含敏感信息,如密码、信用卡信息、社会安全号码等。如果这些数据未加密,那么Wi-Fi网络或本地网络上的任何人都可以窥探和检查该流量。SSL的目标是确保双方能够安全通信,而无需事先商定安全信息。

SSL使用公钥密码学。每对通信方都有一个公钥和一个私钥。服务器处理拥有证书对的工作,浏览器自动处理密钥交换。关键步骤是服务器获取与其域名匹配的SSL证书。这通常通过DNS或其他方法证明域名的所有权。Heroku提供免费的SSL证书,虽然不是默认启用,但在应用设置中是一个选项。建议每个人都使用它,因为它免费且易于设置。

一旦服务器向证书机构证明了自己,证书机构将向服务器发回一个私钥,用于生成与域名匹配的SSL证书。你将该证书安装在服务器上(Heroku会自动处理)。浏览器会验证该证书是否来自受信任的证书机构。验证通过后,SSL会进行对称密钥加密,交换一个更长但更安全的对称密钥用于加密,因为公钥密码学步骤较多,速度较慢。

在Rails中,你可以在应用级别设置force_ssl选项,将所有流量重定向到SSL。在设置好SSL证书之前不要开启此选项,否则如果证书无效,浏览器会拒绝请求。

重要的是要理解SSL不做什么。SSL不保证用户就是他们声称的那个人,也不保护数据到达服务器后的安全。服务器收到数据后解密,然后可能进行不安全的HTTP调用。作为用户,你无法知道应用在幕后做了什么。大多数网络钓鱼网站,如果做得不错,也会设置SSL。因此,仅凭锁形图标并不能保证一切安全。

数据库安全与SQL注入

接下来要关注的是数据库安全。经典的XKCD漫画说明了SQL注入的风险。其风险在于,每当我们在应用中填充数据时,这些数据会被插入到SQL查询中。我们必须确保以安全的方式将这些数据传递到SQL查询中,使得类似'; DROP TABLE students; --的输入不会真的删除数据库表。

大多数应用框架都很好地处理了这个问题,但也容易出错。永远不要编写直接将字符串插入原始SQL查询的SQL查询。这似乎很自然,但绝对不要在应用中这样做。有一些工具可以帮助监控和分析你的代码,确保你不会这样做。

在Rails中,解决方案是使用问号并将参数传递给Rails,由Rails安全地转义并插入到查询中。数据库引擎也有自己的方式。这是首选方法。你看到的每个Active Record查询示例都应该类似这样。在查询中使用问号是让Rails确保传递的数据不会受到SQL注入攻击的一种方式,它通过转义数据来实现。

跨站请求伪造

下一个漏洞是跨站请求伪造。其思路是,如果我是一个恶意网站,我可能试图向银行网站发出请求,看看是否能以该用户的身份登录。这利用了用户可能没有意识到他们正在登录某个网站的情况。

每当我登录bank.com时,银行会在我的浏览器中设置一个Cookie,以便在后续请求中识别已登录的用户。然后,当我访问其他网站(比如一个随机博客)时,该网站可能想通过登录并获取你银行的Cookie来冒充你的用户。他们可能会尝试嵌入一个试图访问银行网站的图片,该请求会携带Cookie。虽然现代浏览器使这变得更困难,但本质上,其思路是在你的页面上嵌入一些数据,试图向其他网站发出请求,从而嗅探该请求的Cookie。

如果没有保护措施,另一个域访问另一个域的Cookie理论上会让他们获得访问权限。可能的解决方案包括检查HTTP请求的Referer头(注意拼写错误是原始规范中的遗留问题),但这不是很可靠,因为它可能被伪造。跨源资源共享是一种向浏览器指定允许和不允许哪些域发出请求的方式。默认情况下,浏览器会阻止来自非自身域的请求。CORS的挑战在于,它依赖于客户端(浏览器)来强制执行这些规则。

Rails默认提供的最强保护形式是CSRF元标签。如果你检查页面HTML,特别是在表单上,会看到一个隐藏的输入字段,其中包含一个特殊的安全令牌,该令牌为每个页面和该页面上的每个表单生成。这些是防伪令牌,随请求一起发送,Rails内部会验证该令牌是否只使用过一次,是否来自该域。这提供了非常好的跨站请求伪造保护。该令牌必须来自你的服务器。如果请求来自其他服务器,则不会有该令牌。用户只有在已登录并加载页面时才能获得该令牌。

你可以在应用控制器中使用protect_from_forgery来设置此功能。在Rails 4及更高版本的应用中,默认是开启的。如果你构建的是仅API应用,可能有更好的处理方式,例如使用不同的身份验证方法。默认情况下,Rails为你提供了保护选项。在不是Rails的框架中工作之前,很难体会到Rails内置了多少安全功能,但它确实存在且非常有用。

安全知识测验

如果一个网站拥有来自受信任证书机构的有效SSL证书,以下哪些说法是正确的?

  1. 该网站可能没有冒充其他网站。
  2. CSRF和SQL注入攻击更难对该网站实施。
  3. 我们的数据在到达该网站后是安全的。

正确答案是只有第1项。SSL证书的目的是验证你正在访问的域名就是它声称的域名。但这并不意味着该域名本身是合法的。SSL并不能使CSRF或SQL注入攻击更难实施,因为这些攻击取决于应用代码。SSL也不能保证数据到达网站后的安全,这完全取决于应用开发者。

在2019年,网站没有SSL证书是不可接受的。Heroku免费提供,如果你运行自己的网站,有一个很棒的服务叫Let's Encrypt,它提供自动续期的SSL证书。使用Rails,你基本上可以免受CSRF和SQL注入攻击。而数据到达服务器后的安全,则由你作为应用开发者负责。

应用可扩展性

最后,假设我们保证了数据安全,如何扩展我们的应用?我们需要什么工具来理解应用如何随用户增长而扩展?

理想情况下,如果我们正在构建一个产品,我们希望拥有更多用户,因为这会带来更多收入。支持更多用户时,我们必须确保仍能提供良好的体验。可扩展性意味着许多不同的事情,我们主要讨论的是如何从小规模用户扩展到合理数量的用户。

我们关心响应时间,这是最常见的面向用户的指标。随着用户增加,我们希望响应时间保持较短。我们希望在不浪费金钱的情况下支持更多用户。虽然可以通过投入资金解决问题,但我们并不总是有资金优势。因此,我们希望在增加用户的同时保持成本相似。

我们通过服务级别目标来衡量这些指标。这是特定时间段内特定指标的目标。通常是延迟(从在地址栏按回车到获取内容并完全加载所需的时间)或正常运行时间。SLO通常通过百分位数来衡量。我们有数百万个具有不同响应时间的请求。我们应该选择最快的吗?那太乐观了。选择最慢的吗?最慢的情况通常非常慢,且可能无法完全避免。因此,我们通常选择一个百分位窗口,如第95百分位、第99百分位或第50百分位(中位数)。这是在特定时间窗口内衡量的。

总结

本节课中我们一起学习了持续集成与持续部署的实践,了解了如何通过自动化测试和部署流程提高开发效率。我们还深入探讨了Web应用安全的核心领域,包括使用SSL保护数据传输、防范SQL注入与跨站请求伪造攻击,以及通过功能开关安全地进行数据迁移和功能发布。最后,我们简要介绍了应用可扩展性的基本概念和衡量指标。掌握这些DevOps与安全实践,对于构建健壮、可维护且安全的现代Web应用至关重要。

022:DevOps与性能优化

在本节课中,我们将完成关于DevOps内容的讨论,主要聚焦于应用的可扩展性和性能优化。我们将学习如何衡量应用性能、识别瓶颈,并探讨提升响应速度的实用策略。

性能衡量与服务等级目标

上一节我们介绍了DevOps和可扩展性的基本概念。本节中,我们来看看如何具体衡量应用性能。

当我们谈论可扩展性时,核心目标是让应用能够支持更多用户,同时避免成本急剧上升。服务增长时,增加投入以应对需求是自然的,但关键在于如何以稳定且经济高效的方式实现。

为此,我们使用服务等级目标来定义可接受的性能标准。SLO通常包含一个比率和一个时间周期。例如,“过去一个月内99%的正常运行时间”或“一年内99.99%的正常运行时间”。我们常用“几个9”来描述可用性,例如“4个9”代表99.99%。

Apdex分数:量化用户体验

衡量响应时间的一个常用指标是Apdex分数。这是一个介于0到1之间的简单分数,0代表糟糕,1代表优秀。

其核心思想是:统计在合理响应时间内完成的请求比例,并考虑不同请求耗时差异。我们定义一个阈值T(例如1秒):

  • 响应时间 ≤ T 的请求为“满意”。
  • T < 响应时间 ≤ 4T 的请求为“可容忍”。
  • 响应时间 > 4T 的请求为“沮丧”。

Apdex分数的计算公式如下:

Apdex = (满意请求数 + 可容忍请求数 / 2) / 总请求数

高分(如0.93以上)表示性能良好。但需注意,Apdex会忽略极慢的请求(不纳入计算),而这些请求有可能拖垮整个网站,因此需要额外监控。

关键点:阈值T的选择取决于具体应用,没有固定答案。1秒或1.5秒是常见的起始值。

响应时间为何重要

了解性能指标后,我们来看看为什么优化响应时间至关重要。

大型网站通过实证研究发现,响应时间的微小延迟会显著影响业务指标:

  • Amazon:响应时间每增加100毫秒,销售额下降1%。
  • Yahoo:响应时间增加400毫秒,流量下降5-6%。
  • Google:响应时间延迟0.5秒,导致搜索量减少20%。

这些公司可以通过实验(如人为添加延迟)来验证这些影响。

对于用户体验:

  • 小于100毫秒的延迟被认为是“瞬时”的。
  • 超过1秒,用户会开始切换注意力。
  • 超过3-7秒(容忍度随时间降低),用户很可能放弃并离开。

正如Google研究员Jeff Dean所言:“速度本身就是一项功能。” 对于搜索引擎这类核心产品,速度直接关系到收入和用户留存。

剖析请求生命周期

要优化性能,首先需要理解时间花在了哪里。当一个请求发出时,其生命周期包含多个阶段:

  1. 网络传输:请求从用户设备发送到服务器。
  2. 服务器处理:Web服务器(如Rack)、Rails控制器、模型方法、数据库查询、视图渲染。
  3. 网络返回:服务器将处理好的数据分批次发送回用户浏览器。
  4. 浏览器渲染:浏览器逐步接收数据并渲染页面。

我们的优化目标是缩短总响应时间。同时,我们特别关注 “首屏时间” ,即用户首次看到有用内容的时间。即使页面未完全加载,尽早显示内容也能极大提升用户体验。

前端优化(如CSS/JS加载顺序)对首屏时间影响巨大,可以使用Chrome开发者工具的“Audits”选项卡进行自动化测试。本课程将主要关注服务器端的优化。

监控性能:发现瓶颈

“如果你没有监控,那么它很可能已经出问题了。” 这意味着,等到用户报告问题时,通常已有大量用户受到影响。

在开发环境,我们可以分析应用性能。在生产环境,我们需要使用监控服务,如New Relic、Scout等。这些服务通常提供免费套餐,易于集成。

监控服务的工作原理是:在你的Rails应用中安装一个gem(并配置API密钥)。在每个请求处理完毕后,该gem会在后台将本次请求的耗时、状态等数据发送到监控服务器。

以下是监控工具能提供的关键信息:

  • 整体概览:应用响应时间、吞吐量、错误率图表。
  • 数据库分析:SQL查询数量、读写比例、连接数。
  • 代码级剖析:显示每个控制器动作、方法消耗的时间百分比。这是优化的起点。

通过代码级剖析,你可以发现两类优化目标:

  1. 单次很慢的方法:例如复杂的后台管理查询。
  2. 频繁调用的方法:即使每次只消耗中等时间,但由于调用频繁,总耗时占比很高,优化它们收益显著。

优化策略(全):数据库

分析完监控数据后,我们开始着手优化。最常见的瓶颈在数据库。

ActiveRecord虽然强大,但很容易写出低效查询。我们的首要目标是尽可能使用单一数据库,因为跨数据库的分布式事务会带来巨大复杂性。

1. 避免 N+1 查询问题

N+1查询是常见性能杀手。例如,查询所有影迷及其看过的电影:

# 低效写法:产生 N+1 次查询
@fans = Moviegoer.all
@fans.each do |fan|
  fan.movies.each do |movie| # 每次循环都会触发一次数据库查询
    puts movie.title
  end
end

上述代码会先执行1次查询获取所有影迷,然后为每个影迷执行1次查询获取其电影,总共N+1次查询。

解决方案:使用 includes 进行预加载

# 高效写法:仅产生1次查询(使用JOIN)
@fans = Moviegoer.includes(:movies).all
@fans.each do |fan|
  fan.movies.each do |movie| # 数据已预加载,不会触发新查询
    puts movie.title
  end
end

includes 方法通过单次JOIN查询提前获取所有关联数据,极大减少了数据库往返次数。

辅助工具bullet gem 可以在开发环境中自动检测N+1查询,并在日志中给出警告。

注意:只预加载真正需要的数据。如果预加载了关联数据却未使用,反而会造成浪费。

2. 使用数据库索引

索引是加速数据库查询的利器。可以将其理解为书籍的目录,它能帮助数据库快速定位到特定数据行。

默认情况下,数据表的主键(id)自带索引。我们应该为以下列添加索引:

  • 外键:例如 moviegoer_id, movie_id
  • 常用于查询条件的列:例如 email, status
  • 常用于排序的列:例如 name, rating(注意:频繁更新的列如 updated_at 需谨慎添加)。

索引的代价

  • 占用存储与内存
  • 降低写入速度:每次插入、更新或删除数据时,数据库都需要更新相关的索引。

因此,索引是用空间和写入性能换取读取性能的权衡。需要根据应用的读写模式谨慎添加。

辅助工具lol_dba gem 可以扫描你的模型,找出可能缺少索引的外键。pghero gem 可以提供更深入的PostgreSQL性能分析。

优化策略(二):缓存

当数据库优化达到一定瓶颈时,缓存是下一步利器。计算机科学中有句名言:“缓存有两难:命名和缓存失效。” 后者正是缓存的核心挑战。

缓存的本质是避免重复计算或查询,将结果暂存在更快的存储中(如内存)。Rails提供了多级缓存:

  1. 数据库缓存:数据库自身对查询结果的缓存。
  2. 片段缓存:缓存视图(View)中的某个局部(如一个部分模板)。
  3. 动作缓存:缓存整个控制器动作的输出。
  4. 页面缓存:缓存整个HTML页面(绕过Rails栈,最快)。
  5. HTTP缓存:利用浏览器和CDN缓存静态资源。

片段缓存示例

片段缓存允许你缓存视图的一部分,特别适用于渲染集合或复杂部分:

<% @movies.each do |movie| %>
  <% cache movie do %>
    <%= render movie %>
  <% end %>
<% end %>

Rails会以movie对象作为键来存储其渲染结果。下次遇到相同的movie时,直接使用缓存结果。

缓存失效

缓存最大的难题是何时让旧缓存过期(失效)。例如,当一部电影信息更新后,所有相关的缓存片段都应失效。这通常通过Sweeper(清扫器)来实现,它观察模型变化,并在数据更新时清理相关缓存。

class MovieSweeper < ActionController::Caching::Sweeper
  observe Movie

  def after_update(movie)
    expire_cache_for(movie)
  end

  private
  def expire_cache_for(movie)
    expire_fragment(movie)
  end
end

核心建议:缓存虽强,但不要过早使用。优先进行数据库和代码优化。只有当这些优化不足以满足性能要求,且你愿意承担缓存带来的复杂性(如失效逻辑、内存使用)时,再引入缓存。

总结

本节课中我们一起学习了软件工程中关于性能监控与优化的核心内容。

我们首先了解了如何通过服务等级目标Apdex分数量化应用性能。接着,探讨了响应时间对用户体验和业务指标的关键影响。为了定位问题,我们介绍了使用监控工具来剖析请求生命周期,发现代码与数据库层面的瓶颈。

在优化实践部分,我们重点学习了两种策略:

  1. 数据库优化:通过使用 includes 避免 N+1查询,以及为高频查询列添加数据库索引来提升查询效率。
  2. 缓存策略:作为提升性能的强力手段,我们了解了片段缓存等机制,同时也认识到其带来的缓存失效等复杂性挑战。

记住优化准则:先测量,后优化。不要盲目猜测瓶颈,而是依靠监控数据做出明智决策。对于大多数应用,从数据库优化入手往往能获得最大的性能收益。

023:JavaScript 与 DOM 基础 🚀

在本节课中,我们将要学习 JavaScript 的基础知识,包括其语言特性、如何与 DOM 交互,以及如何在 Rails 应用中有效地使用它来增强用户体验。我们还将介绍一些实用的 Heroku 团队协作工具。


概述

JavaScript 是唯一能在所有现代浏览器中运行的语言,它使我们能够创建动态和交互式的网页。本节课将介绍 JavaScript 的核心概念、语法,以及如何将其作为现有 Rails 应用的增强工具,而不是完全依赖它来构建应用。


Heroku 团队协作工具

在深入 JavaScript 之前,我们先来看看一些对团队项目协作非常有用的 Heroku 工具。

共享应用访问权限

所有团队成员应在同一个 GitHub 仓库中协作。然而,Heroku 的访问权限不会自动与 GitHub 同步。你需要在 Heroku 上为应用添加协作者,这样团队成员就都能推送代码、部署和访问该应用。

你可以通过 Heroku 网站或命令行工具来管理应用。命令行工具非常强大,一旦熟悉,使用起来会非常方便。

常用 Heroku 命令行指令

以下是几个有用的 Heroku 命令:

  • heroku apps:列出你有权访问的所有应用。
  • heroku logs --tail -a <app_name>:实时查看指定应用的日志。
  • heroku run bash:在 Heroku 服务器上运行一个 bash 控制台,用于调试。
  • heroku pg:psql:打开一个 PostgreSQL 控制台,用于直接运行 SQL 查询。
  • heroku local:使用 Procfile 中的配置在本地运行应用,模拟生产环境。
  • heroku config:查看应用的所有环境变量。使用 -s 标志可以输出便于加载的格式。

环境变量与 .env 文件

在开发中,可以使用 .env 文件来存储本地环境变量(如 API 密钥)。务必确保将 .env 添加到 .gitignore 文件中,以防止敏感信息被提交到代码仓库。Heroku Local 和 Foreman 这类工具会自动加载 .env 文件中的变量。


引入 JavaScript

上一节我们介绍了团队协作工具,本节中我们来看看今天课程的核心:JavaScript。

JavaScript 的角色与定位

JavaScript 诞生于 20 世纪 90 年代,最初名为 LiveScript,后来为了蹭 Java 的热度而改名。它是一种在浏览器端运行的解释型、动态语言。

在 Rails 应用中,应尽可能将 JavaScript 用作功能增强,而非必需品。这意味着,即使浏览器禁用了 JavaScript,应用的核心功能(如提交表单)也应能正常工作。对于高度交互的复杂界面(如单页应用),可以考虑使用 React 这类框架,但对于简单的交互,使用原生 JavaScript 或 jQuery 就足够了。

客户端 JavaScript 的能力与限制

JavaScript 可以访问和操作当前页面的所有内容(DOM),例如读取输入框的值、阻止表单提交、动态更新页面内容等。

然而,有一个重要的安全原则需要牢记:永远不要完全信任客户端验证。因为用户完全可以修改或绕过运行在其浏览器中的 JavaScript。所有关键的业务逻辑和数据验证都必须在服务器端重复进行。


JavaScript 语言基础

了解了 JavaScript 的定位后,我们来深入看看这门语言本身的一些核心特性。

基本语法与特性

与 Ruby 类似,JavaScript 中(几乎)一切都是对象。对象类似于 Ruby 的哈希,是键值对的集合。

var movie = {
  title: "The Patriot",
  year: 2000,
  director: "Roland Emmerich"
};

JavaScript 是单线程的。在浏览器中,每个标签页或窗口都运行着独立的 JavaScript 解释器,这主要是出于安全隔离的考虑。

函数、this 与原型继承

函数是 JavaScript 中的一等公民,可以像变量一样被传递和赋值。

this 关键字类似于 Ruby 中的 self,但其指向更具动态性,取决于函数被调用的方式。在全局作用域中,this 指向浏览器环境下的 window 对象。

JavaScript 使用原型继承而非经典的类继承。每个对象都有一个内部链接指向它的原型对象,当访问一个属性时,如果对象自身没有,JavaScript 会沿着原型链向上查找。

在 ES6(ES2015)之前,我们使用构造函数和 new 关键字来模拟类:

function Movie(title, year) {
  this.title = title;
  this.year = year;
}
Movie.prototype.logInfo = function() {
  console.log(this.title + ' (' + this.year + ')');
};
var myMovie = new Movie("Inception", 2010);
myMovie.logInfo(); // 输出: Inception (2010)

注意:如果调用构造函数时忘记了 new 关键字,this 将不会指向新创建的对象,而是指向全局对象(如 window),这会导致错误且难以调试。

ES6 引入了更直观的 class 语法,但其底层仍然是原型继承。

变量声明:var, let, const

  • var:传统声明方式,作用域为函数作用域。
  • let(ES6+):块级作用域变量,可以被重新赋值。
  • const(ES6+):块级作用域常量,声明后不能重新赋值(但对象或数组的内容可以修改)。

现代 JavaScript 开发中,推荐使用 letconst 来替代 var,因为它们能提供更精确的变量作用域控制,减少错误。

代码组织与模块化

为了避免污染全局命名空间,应将代码封装在模块或函数作用域内。一种常见的模式是“立即调用函数表达式”(IIFE):

(function() {
  // 你的代码在这里
  var privateVariable = 'hidden';
  function privateHelper() {
    // ...
  }
  // 暴露给外部的接口
  window.myModule = {
    publicMethod: function() {
      // 可以使用 privateVariable 和 privateHelper
    }
  };
})();

在 Rails 中,你可以将 JavaScript 代码放在 app/assets/javascripts 目录下,资源管道(Asset Pipeline)会自动将它们合并、压缩并包含在应用中。


总结

本节课中我们一起学习了 JavaScript 在 Web 开发中的核心作用。我们明确了应将其作为 Rails 应用的增强工具,并理解了客户端验证的局限性。我们探讨了 JavaScript 的基本语法、函数、this 关键字的特性,以及原型继承的概念。最后,我们介绍了使用 let/const 进行变量声明以及通过模块化模式来组织代码的最佳实践。在接下来的课程中,我们将学习如何使用 JavaScript 直接操作 DOM 来创建动态交互。

024:前端交互与可访问性

在本节课中,我们将学习如何使用JavaScript和jQuery为Web应用添加动态交互,并探讨如何确保这些交互对所有用户都是可访问的。

概述

我们将首先了解jQuery如何简化跨浏览器DOM操作,然后学习使用Ajax进行异步数据交互。接着,我们将探讨如何测试JavaScript代码。最后,课程的重点将转向Web可访问性,学习如何构建让所有用户都能使用的应用。

jQuery:简化DOM操作

上一节我们介绍了前端交互的基础。本节中我们来看看如何使用jQuery库来简化这一过程。

每个浏览器可能拥有不同的API或存在细微的不兼容性。jQuery的作用就是消除这些不兼容性。jQuery经过数十年的发展,已经处理了许多浏览器错误。因此,使用jQuery意味着理想情况下,有人已经在你之前处理了这些浏览器错误。

jQuery的一个优点是它提供了一个全局的jQuery对象。这个对象最常被别名为美元符号变量 $

以下是使用jQuery操作DOM的典型接口。你并非必须使用jQuery,但在速度和效率方面,通过简单的选择器(如传递类名)来选择元素能带来很大价值。

调用jQuery有多种方式。其核心功能是选择元素。使用 $(selector),你会得到一个元素数组。然后你可以使用 .each() 方法并传入一个函数来操作这些元素。例如,你可能有一系列计数器需要更新,或者需要更新一组链接的用户名。

你可以将单个原生DOM元素包装在jQuery选择器中。例如,在JavaScript的点击事件处理函数中,this 可能指向被点击的按钮。执行 $(this) 就将该元素包装成了jQuery对象。

如果你想访问像整个窗口这样的对象,也可以将其包装进jQuery,例如 $(window)$(document.body)

jQuery有一个非常方便的语法:如果你传入一个带尖括号的HTML标签,它会创建一个新元素。例如,传入 <span> 会创建一个空的span元素。如果你在进行一些基本的前端操作,这是一个非常方便的工具。

jQuery的另一个优点是支持方法链式调用。它还提供了一个方便的回调函数,用于在文档准备就绪时执行设置代码:$(document).ready(function() { ... })

这是一些简单的jQuery工具参考。和所有API一样,无需一次性学完,可以随时间逐步掌握,但要知道它们的存在。jQuery文档非常全面且及时更新,提供了大量示例和交互式查看器。如果你还没用过,我鼓励你使用jQuery,它会让处理浏览器错误变得更容易。

Ajax:实现异步数据交互

如果jQuery和JavaScript赋予我们修改网页的能力,那么Ajax就是让我们能够通过远程数据访问使网页变得交互的工具。

Ajax代表“异步JavaScript和XML”。它本质上是一种发起Web请求并获取数据返回的技术。

我们之前已经简单讨论过:Web应用中有一些函数和一些需要响应的事件。当事件发生时,我们可以调用远程Web服务,访问一些数据。返回的响应是Web请求的结果,我们可以用JavaScript处理它,并相应地更新应用。

自然地,大多数现代Web应用都以某种方式使用Ajax来完成工作。例如,在Facebook上,当你开始无限滚动时,它会发起一个Ajax请求来加载页面初次加载时未显示的额外帖子。当你给帖子点赞时,那也是一个Ajax请求,用于将点赞保存到Facebook,而不是重新加载整个页面。其工作原理是,每个按钮都有一个回调处理函数,该函数发起Ajax请求,之后你可以任意操作DOM,添加或更改内容。你可以根据需要发起任意数量的Ajax请求。

这里重要的是能够测试我们的Ajax代码。我们有一个Rails应用,目前的Cucumber和RSpec设置主要用于测试Rails相关的内容。我们需要确保测试套件能够测试JavaScript代码。

在Cucumber和RSpec中,你都可以用 :js 标签来标记测试,告诉它在浏览器中运行JavaScript(默认可能不运行)。如果在设置新测试套件时遇到棘手情况,可以在Piazza上发帖,因为让测试在浏览器中良好运行有一些技巧。

我们还将讨论如何将Ruby中的测试理念应用到JavaScript代码中。Jasmine是JavaScript中的一个测试工具,Jest是另一个,此外还有很多其他工具。

如何操作DOM?

通常,我们会将某个函数绑定到一个元素上。我们可以用jQuery这样做:$(element).on('click', ...)。为了测试这一点,我们必须确保处理函数被调用。

因此,我们将编写自己的函数。在测试时,我们需要确保有能力测试这一点。我们使用jQuery选择元素,编写执行工作的函数,并使用 .on('click').on('keypress').on('scroll') 等事件处理程序来触发回调。

让我们看一个例子。这里有一些简单的HTML,只有一个段落、一个“显示详情”的链接和一个复选框。在页面的其他地方,我们有一些JavaScript代码。

代码结构如下:我们可以将东西放在一个对象中以进行分组,但这并非必需。我们有一个 hide 函数。当点击某个元素时,如果复选框被选中(jQuery提供了 :checked 语法来检查这个布尔状态),我们就显示一些内容;如果未选中,我们就使用 slideUp()(这是一个内置的jQuery动画,你也可以直接调用 hide()show())来隐藏内容,并切换一些状态。我们还有一个 setup 处理函数,当复选框状态改变时,调用我们的 hide 函数。

通过将代码组织在一个对象中,我们可以直接将其传递给全局的jQuery处理程序,这是jQuery提供的一种快捷方式。当然,你也可以用几十种不同的方式设置,选择你习惯的方式即可。

这里我们有的是:更新内容的回调函数、设置函数以及用jQuery初始化。这就是使用jQuery操作DOM的要点。从这里开始,你可以从一些简单的事情入手,但很快就会发现JavaScript很容易破坏功能。因此,我们需要思考如何测试应用。

测试JavaScript代码

就像我们用Rails实践测试驱动开发一样,我们也可以用JavaScript代码实践TDD。我们将讨论具体如何操作,但其工作原理基本相同。

我们使用一个名为 Jasmine 的库,它为我们提供了类似RSpec的JavaScript测试。Jasmine的工作方式与大多数TDD库类似:我们有 describe 块来分组测试。在JavaScript中,所有东西都是函数调用,所以语法上比Ruby稍显冗长,但本质相同。

describe 将一系列测试用例分组,每个测试用例使用 it 而不是 do,后面跟一个函数。在程序行为上,两者几乎相同。

我们有 beforeEachbeforeAll 函数用于设置。在设置块中,我们定义一个匿名函数来执行所需的任何设置工作,包括设置我们的网页和前端内容。

期望(断言)的工作方式也几乎相同:expect(someExpression).toEqual(...)。如果RSpec中有某个方法,Jasmine中几乎肯定有等效的方法。

对于JavaScript,toBeHiddentoBeVisible 非常有用,因为你经常需要隐藏和显示内容。toBeHidden 的好处在于,无论你使用CSS的 display: none 还是HTML的 hidden 属性,它都能正确处理,你无需检查特定的CSS类。

你可以检查特定文本,当然也可以做所有在RSpec测试中可能做的事情,但操作的是JavaScript代码。

Jasmine-jquery 添加了一些额外的方法,确保你在单个测试用例中可以使用jQuery来操作元素。

我们可以编写一个简单的测试用例:“点击隐藏按钮会隐藏电影div”。当我们触发点击时,我们期望电影div被隐藏。这样声明性地工作:我们创建一个测试用例,不是写Rails代码,而是写一段模拟点击的jQuery代码片段。我们可以用特定的键盘值触发按键事件等等。动作发生后,我们可以期望某个div被隐藏。这本质上就是我们的RSpec测试,但操作的是JavaScript代码。

Jasmine使用 spies 来监视或创建替代真实方法的方法,让我们可以检查它们是否被调用,类似于RSpec中的mocks和doubles。

如果我们想监视某个东西,可以使用 spyOn 方法,传入对象和方法名,并让它返回特定值。我们可以询问它是否被调用。当我们调用某个函数时,可以设置spy来检查 newMoviePopup 是否被正确调用。然后我们可以使用 and.callFake,传入一个我们定义的函数,用于在测试用例中指定发生什么。例如,如果我们想返回一个假的电影实例,或者存根某个特定的Ajax请求,我们可以选择这样做。

重要的是,spyOn 传入的是实际的对象本身。例如,MoviePopup 是我们代码中存在的一个JavaScript对象,我们传入该对象的准确名称。

这里有几个例子:expect(MoviePopup.new).toHaveBeenCalledWith('gravity') 可以检查这个函数是否被以 'gravity' 为参数调用。我们可以断言Ajax请求的URL是 '/movie/1'。就像我们的单元测试一样,我们可以存根尚未定义的值,并断言它们是以正确的数据调用的。这些都是有用的工具。

HTML fixtures 类似于Rails中用于对象的fixtures,但包含的是HTML片段。同样,这不是每个应用都需要,但是一个需要了解的有用工具。它提供了测试特定用例所需的最基本的HTML。问题是,如果我们要为控制器或模型操作编写单元测试,如何为特定的JavaScript片段编写单元测试?我们可以使用HTML fixtures来实现。

一种方法是使用Jasmine的fixtures函数加载HTML片段并进行设置。教科书对此有更详细的介绍。当你的前端变得复杂时,HTML fixtures很有用,但你可能也可以通过足够多的高层集成测试来避免使用它们。对于某些类型的应用,或者当你的控制器操作返回HTML而不是JSON时,这也是一个有用的工具。

当我们存根数据时,在JavaScript中所做的与在Rails中类似:在每个层级上,我们都可以存根东西。例如,我们可以存根单个jQuery调用,也可以存根浏览器API。这种映射不一定是一对一的,因为UI可能并不总是对应我们的模型,但我们有能力进行存根。

整合示例:动态电影详情

我们可以将所有内容整合起来。每次我们展示的JavaScript都更复杂一点,例如扩展电影信息div的细节,这需要响应一些JSON数据。

在我们的测试中,我们可以定义仅针对此JavaScript运行的Jasmine测试。我们为设置函数编写测试,为点击链接编写测试。需要注意的一点是,当我们使用jQuery时,如果不进行存根,那将是一个异步调用。但通过存根和监视jQuery,我们可以直接断言某些东西被调用,而无需实际发起HTTP请求,这简化了测试结构。

然后我们可以存根HTML响应。当我们期望使某些内容可见时,我们也可以使用标准的断言。我们在RSpec中拥有的一切,在Jasmine测试中都可以有完全相同的结构。目标是,当我们的JavaScript变得复杂时,我们希望有测试用例来覆盖它。

在Rails中包含JavaScript

如果你还没见过,我们如何在Ruby应用中包含JavaScript?我们有一个非常好的工具叫 javascript_include_tag。默认情况下,资源管道为我们提供了一个 application.js 文件,这是我们所有应用JavaScript的入口点。它位于 app/assets/javascripts。如果你使用Rails 6和Webpack,设置略有不同,但这仍然可用。

在那里,我们可以定义任何我们想要的JavaScript函数。重要的是,在你的Rails应用中,将JavaScript分割成多个小文件,因为Rails会为你将它们打包成一个加载的包。多个小文件再次有助于可维护性。

在那里,我们定义所需的任何函数,包括调用Ajax。然后,在我们的Rails应用中,我们将研究如何定义响应Ajax的控制器操作。在我们的JavaScript中,我们将定义所需的任何回调函数。在客户端,我们编写JavaScript,Rails将其放在正确的位置。然后由我们的服务器端来处理。

根据你的请求,你可能需要不同的路由,或者你可能使用相同的路由来处理多个不同的视图,这完全可以。

示例:Rotten Potatoes应用

这是我们的Rotten Potatoes应用。我们有一个显示电影的路由。默认情况下,这只是信息页面:views/movies/show.html.erb。假设我们想要一个Ajax请求,只显示电影的部分数据。

在这种情况下,我们可以只渲染一个局部视图,即只返回关于电影的一些HTML。我们如何检查呢?Rails提供了 request.xhr? 方法,Rails用它来检查请求是否通过JavaScript发起。它检查 X-Requested-With 请求头,只要该请求头存在,就会渲染这个局部视图,而不是我们默认的或其他视图。

客户端,我们再次将东西分组到一个对象中,使用常见的设置方法。在这种情况下,创建一个弹出窗口,隐藏div,但将其附加到我们的body。当我们点击电影链接时,将调用 getMovieInfo 函数。getMovieInfo 会创建一个发起Ajax请求的函数。当Ajax请求成功时,它将显示一些电影信息。我们设置的方式有多种,但核心思想是:我们有一个可以点击的元素,点击时发起返回数据的请求,然后将数据插入网页的DOM中。

在这个例子中,我们还定义了一个 hideMovieInfo 函数,用于隐藏我们刚刚添加的信息。根据你的应用需要,你可以交互式地添加这些功能。

关于Ajax的辨析

关于Ajax请求与非Ajax交互,哪一项说法是错误的?正确答案是D:如果Ajax请求失败或服务器无法响应,浏览器的UI不会冻结。原因是,尽管JavaScript是单线程的,但浏览器会在后台等待并在响应返回前处理其余JavaScript,这就是我们使用回调而不是顺序编写代码的原因。

Ajax请求可以由自己的控制器操作处理。这取决于你调用有意义的路由。如果你想有一个仅用于Ajax的路由,可以这样做;如果你想指定返回JSON的Ajax请求,也可以这样做。

通常,服务器必须依赖明确的提示。这是真的。当我们向服务器发起Web请求时,服务器除了随请求发送的请求头外,对请求来源一无所知。浏览器会发送 X-Requested-With 请求头,不同的应用可能会发送自己的请求头。但如果该请求头不存在,我们无法知道该请求是来自浏览器还是其他API等。

对Ajax请求的响应可以是任何内容。这是真的。无论我们决定返回什么,我们都拥有完全的控制权。可能是文件、HTML,最常见的是HTML或JSON,但你可以自行决定。

测试与调试JavaScript

现在我们已经有机会编写一些JavaScript,我们将希望能够测试和调试它。

在浏览器中,调试JavaScript的最佳位置是使用浏览器开发者工具。

在我们的测试用例中,如果有Cucumber或RSpec集成测试,我们需要使用模拟Web浏览器的东西来运行它们。如果你已经在使用Cucumber,它应该已经设置好了。

Poltergeist 是一个选项,它有一个本地无头浏览器版本。“无头”意味着它在内存中有一个浏览器实例,但不显示页面,而是执行所有相同的操作。

最近,Google Chrome与Capybara和测试有很好的集成。你可以安装 ChromeDriver,它也有无头选项。设置起来有时可能有点棘手。

无论我们使用什么,我们都说 Capybara.register_driver。有一个叫 poltergeist,一个叫 chromedriver。你将它们添加到你的Gemfile中。你将在本地拥有某个浏览器(无论是Chrome还是Poltergeist)的版本。你告诉Capybara对JavaScript使用什么。

但在我们自己的测试中,我们可以使用 page 对象。page 是代表我们网页实例的全局变量。我们可以调用 page.driver.debug。这会在Rails控制台中给我们一个REPL,让我们与浏览器交互。我们有很多其他有用的工具:我们可以保存页面或保存打开的页面,这将保存HTML并打开该文件;我们可以截图;我们可以设置任何类型的断点,并从测试内部检查我们的JavaScript或Ruby代码。因此,我们的JavaScript工具将让我们能够从Rails测试内部调试JavaScript。如果你遇到一些前端测试不通过的问题,使用 page.driver 是解决它们的有用方法。

但更有用的可能是在JavaScript中更直接地测试我们的JavaScript。我们将快速浏览一下,以便有时间讨论更有趣的可访问性内容。

Jasmine:JavaScript的测试框架

Jasmine本质上是JavaScript的RSpec等效工具。它的优点是,如果我们添加了Jasmine gem,并且如果我们在使用jQuery,jasmine-jquery 添加了一些额外的辅助工具,我们就能获得Rails工具,自动在标准位置设置我们的测试。同样,在我们的 spec 文件夹中,如果我们运行 rake jasmine,它将在自己的服务器环境中仅运行我们的JavaScript测试。

然后我们将研究如何构建这些测试,以及如何查看它们应该操作什么。

Jasmine的工作方式与大多数TDD库类似。我们有 describe 块来分组测试。在JavaScript中,所有东西都是函数调用。在Ruby中,我们说 describe,那是Ruby中自己的函数,但因为Ruby有可选的括号,它看起来像自己的迷你语言。在JavaScript中,我们必须加上括号并使用 function 关键字,所以它稍微冗长一些,但本质上做的是完全相同的事情:describe 将分组一系列测试用例,每个测试用例使用 it 而不是 do,后面跟一个函数。

在程序行为上,这两者几乎相同。我们有 beforeEach 函数,如果需要一次性设置块,还有 beforeAll 函数。我们调用这个函数,在其中定义一个匿名函数来执行任何需要的设置工作,这里的设置工作包括设置我们的网页和前端内容。

期望(断言)的工作方式也几乎相同。我们有 expect(someExpression).toEqual(...).toBeTruthy() 等等。如果RSpec中有某个方法,Jasmine中几乎肯定有等效的方法。

toBeHiddentoBeVisible 对JavaScript非常有用,因为你经常需要隐藏和显示内容。toBeHidden 的好处在于,无论你使用CSS的 display: none 还是HTML的 hidden 属性,它都能正确处理,你无需检查特定的CSS类。

你可以检查特定文本,当然也可以做所有在RSpec测试中可能做的事情,但操作的是JavaScript代码。

jasmine-jquery 添加了一些额外的方法,确保你在单个测试用例中可以使用jQuery来操作元素。

我们可以编写一个非常简单的测试用例:“点击隐藏按钮会隐藏电影div”。当我们触发点击时,我们期望电影div被隐藏。这样声明性地工作:我们创建一个测试用例,不是写Rails代码,而是写一段模拟点击的jQuery代码片段。我们可以用特定的键盘值触发按键事件等等。动作发生后,我们可以期望某个div被隐藏。这本质上就是我们的RSpec测试,但操作的是JavaScript代码。

Jasmine使用 spies 来监视或创建替代真实方法的方法,让我们可以检查它们是否被调用,类似于RSpec中的mocks和doubles。

如果我们想监视某个东西,可以使用 spyOn 方法,传入对象和方法名,并让它返回特定值。我们可以询问它是否被调用。当我们调用某个函数时,可以设置spy来检查 newMoviePopup 是否被正确调用。然后我们可以使用 and.callFake,传入一个我们定义的函数,用于在测试用例中指定发生什么。例如,如果我们想返回一个假的电影实例,或者存根某个特定的Ajax请求,我们可以选择这样做。

重要的是,spyOn 传入的是实际的对象本身。例如,MoviePopup 是我们代码中存在的一个JavaScript对象,我们传入该对象的准确名称。

这里有几个例子:expect(MoviePopup.new).toHaveBeenCalledWith('gravity') 可以检查这个函数是否被以 'gravity' 为参数调用。我们可以断言Ajax请求的URL是 '/movie/1'。就像我们的单元测试一样,我们可以存根尚未定义的值,并断言它们是以正确的数据调用的。这些都是有用的工具。

HTML fixtures 类似于Rails中用于对象的fixtures,但包含的是HTML片段。同样,这不是每个应用都需要,但是一个需要了解的有用工具。它提供了测试特定用例所需的最基本的HTML。问题是,如果我们要为控制器或模型操作编写单元测试,如何为特定的JavaScript片段编写单元测试?我们可以使用HTML fixtures来实现。

一种方法是使用Jasmine的fixtures函数加载HTML片段并进行设置。教科书对此有更详细的介绍。当你的前端变得复杂时,HTML fixtures很有用,但你可能也可以通过足够多的高层集成测试来避免使用它们。对于某些类型的应用,或者当你的控制器操作返回HTML而不是JSON时,这也是一个有用的工具。

当我们存根数据时,在JavaScript中所做的与在Rails中类似:在每个层级上,我们都可以存根东西。例如,我们可以存根单个jQuery调用,也可以存根浏览器API。这种映射不一定是一对一的,因为UI可能并不总是对应我们的模型,但我们有能力进行存根。

关于存根的辨析

在这个例子中,如果我们有 and.callFake,为什么我们在Ajax调用中传入 Ajax 而不是 and.return?我们很快地过了一遍,所以如果你没完全理解也不用担心。大多数人都选择了B,这是正确答案:Ajax方法本身并不返回Ajax调用的结果。jQuery中的Ajax方法返回一个包含Ajax请求所有数据的对象,但我们想要的返回数据(HTML或JSON数据)将是异步的。这就是为什么我们有一个 success 函数。因此,当我们处理Ajax请求时,我们不想伪造Ajax方法本身的返回响应,我们想使用 and.callFake 来断言或监视其他属性。这只是需要记住的一点:在JavaScript中,当我们异步操作时,我们通常不一定要看确切的返回响应,而是看该函数被调用时的情况,然后在需要时在不同层级存根东西。这个问题的正确答案是B:Ajax本身并不实际返回服务器内容。

使用JavaScript的注意事项

关于使用JavaScript,总结一下:在我们的服务器端应用中,我们使用Rails。现在也有Node.js的用例存在于服务器端。我们今天讨论的关于客户端操作的内容,都是非常声明性的、逐步使用jQuery操作DOM API的。你们中的一些人可能用过React、Angular。如果你正在构建一个前端繁重的应用,那么选择一个能给你更多结构的框架是很好的。

有了这些,希望我们有足够的JavaScript知识来讨论如何编写JavaScript以及如何构建应用。

当然,我们可以使用许多不同类型的JavaScript框架。Yahoo有一些,你的一些Rails应用可能会使用CoffeeScript。CoffeeScript是看起来像Ruby的JavaScript。如果你有它,使用它没有问题,但如果你开始一个新的Rails应用,就没有必要再使用CoffeeScript了,现代JavaScript特性已经让你实现了90%的功能,无需特殊的编译器或语言。但你可能会在Ruby和Rails应用中看到它,需要注意一下。

如果你不需要JavaScript的复杂性,应该避免使用它。通过最初从服务器请求更少数据来使前端更快,也可能导致发起更多API调用,最终使速度变慢。一页Rails加载所有内容是一次服务器请求。如果我们将其变成一个不渲染任何数据的初始请求,然后用10个API调用替换,我们的总服务器负载可能会增加。我们拥有的前端JavaScript越多,就需要处理越多的浏览器API和各种不兼容性。所有这些都使测试和调试变得更加复杂。也就是说,大多数应用,尤其是大型团队,正朝着更多前端JavaScript的方向发展,因为这是人们想要的。

Web可访问性 🎯

对于作业8有用且非常重要的一点是:我们如何以可访问的方式编写JavaScript,以服务于广泛的用户?

关于Web可访问性,重要的是:对于我们大多数人来说,在使用计算机时,我们不一定考虑我们使用的工具如何与我们不需要使用的辅助技术交互。我们将看一些例子。但我们在构建良好应用时讨论的内容,如HTML标记、良好设计,这些都会以我们可能没有意识到的方式影响用户。

因此,我们将讨论一下Web可访问性。首先,简要谈谈为什么这很重要。这里有一些较旧的资料,但非常有趣:至少拥有正确的可访问性工作心态的一个有趣挑战是,找出如何以非常有趣的方式解决问题。

这是一篇几个月前的文章,但《纽约时报》有一篇非常酷的文章,讲述了如何以便于轮椅用户使用的方式设计公寓(当然,门更宽)。因此,建造步入式淋浴间或步入式衣橱等有助于轮椅用户的功能,实际上也使空间感觉更豪华。这是利用可访问性设计的需求来为每个人建造感觉更好的空间。

可访问性的另一面是,如果你不以可访问的方式构建东西,你可能会被起诉。这是一篇关于画廊所有者因为他们的网站(图片很多)对视障人士不可访问而被起诉的文章。他们常说,如果你有视觉障碍,为什么要访问关于图片的网站?可能有人在研究,有很多潜在原因。但这提醒我们,如果你经营一家企业,不使你的内容可访问,那么不那么有趣的事情就是你可能会因此被起诉。

从最广泛的意义上讲,当我们谈论Web可访问性时,我们谈论的是为所有人构建可用的工具,无论他们是否有残疾,无论他们处于什么情况。这很重要,因为这可能是:你在一个光线良好的房间里使用电脑,然后你走到外面,发现网页上的文本对比度太浅,不易阅读。这可能意味着今天,你的大多数用户没有任何特殊需求,但随着时间的推移,人们变老,他们也会有不同的需求。

就用户数量而言,让我们猜一下美国有多少用户有某种残疾。美国大约有3.3亿人,所以大约有0.33%、1%、10%和20%。正确答案是D:大约五分之一的美国人有某种类型的残疾。这种残疾可能是由于受伤导致的暂时性残疾,也可能是认知障碍、运动障碍、视觉障碍等等。因此,我们在这里考虑的用户数量通常相当大。

还有许多其他统计数据:美国平均每四个成年人就有一个有残疾;随着年龄增长,你会出现残疾;根据地点,65岁以上的人中有三分之一有残疾;根据新罕布什尔大学的数据,全球大多数人认为的平均比例是七分之一,但数字很难追踪。

我们将这些分为五个广泛的类别,当然,这些并不能涵盖所有类型的残疾,但它们帮助我们思考如何调整我们的应用:听觉(如果你有听力障碍,需要什么才能成功使用应用)、认知或神经障碍(如ADHD、阅读障碍,阅读和理解信息所需的工具)、视觉障碍(失明、部分失明)、身体障碍(运动残疾,如果不能使用鼠标如何使用Web应用)和言语障碍(如果不能说话,需要做什么不同的事情来与应用交互)。对于我们的大多数应用,我们将重点关注认知、视觉和身体障碍,因为这些影响与Web浏览器交互的工具。但对于一些有视频等内容的应用,提供文字记录可能是解决听力障碍或完全失聪用户需求的方法。

同样,可访问性的需求通常是情境性的。你可能从明亮的房间走到黑暗的房间。你可能暂时手腕骨折无法打字。这对每个用户和随时间推移都可能改变。

除了法律案例,还有很多事情,因为这是一个非常有趣的问题。如果我们考虑为每个人设计的需求,通过尝试构建包容性设计,我们会得到许多非常有趣的结果。

路缘坡道是20世纪50年代从伯克利开始的东西,现在随处可见,部分归功于《美国残疾人法案》。但路缘坡道是我们每天使用而不一定意识到的东西。它们最初是为轮椅用户设计的,但任何有行李箱的人都会欣赏使用路缘坡道,而不是试图把它拉上实际的路缘。文本转语音技术,如Siri听写,这些工具最初是为不能说话的人提供机会。短信最初是为不能听或不能使用电话的人设计的原型。因此,我们有很多技术存在,因为我们试图解决否则会排除某些人群的问题。这里有很多有趣技术的例子。

挑战在于,当我们开发网站时,如何使它们对所有人都可用和可访问。

可访问性实践要点

我们将介绍一些你会遇到的入门要点,这些在作业中也有概述。

颜色对比度是一个重要的概念。这是伯克利的品牌网站。它定义了如果你要构建使用伯克利颜色的网站,所有他们为我们构建的漂亮调色板。问题是,我们如何决定文本等颜色组合在一起?

关于可访问性工作的一个很酷的事情是,有很多工具可以从数学上让我们决定是否可以将两种颜色组合在一起,使其对大多数用户可用或可读。这是一个很棒的网站,链接在作业中。它告诉我们两种颜色是否符合对比度要求。

其工作原理是:有一个公式将对比度从1排名到21。我们的目标是达到4.5:1的对比度比率。21是最高对比度(全白对全黑),1是最低对比度(白色对白色,对任何人都不可读)。有些人可能能阅读像浅黄色背景上的白色文字这样的内容,但很明显,在很多情况下,大多数人无法很好地阅读它。

W3C(定义HTML规范的团体)有一个既棒又有些复杂的文档,称为Web内容可访问性指南,定义了使应用可访问的规范。这些文档中的内容之一是关于颜色对比度的公式和查看方法。

根据规范,我们Web应用的目标是4.5:1。这并不完美,4.5:1的对比度对所有人来说并不足够,但对大多数用户大多数时间来说已经足够。这是伯克利的蓝色和金色,可以很好地组合在一起。我们可能可以用他们定义的其他颜色替换,比如这种Founder‘s Rock颜色,一种漂亮的蓝色。但如果我们想使用这种蓝色,我们不能在深蓝色背景上使用它,因为没有足够的对比度。这里的目的是有一个工具可以很好地检查这一点。如果我们想看看在黑色背景上如何,它在黑色上显示得很好。如果在白色上尝试,对比度比率略低于白色,这意味着对于大文本,我们可以使用3.0:1的目标值。这里的细节不太重要,因为我们应该让计算机为我们做自动化测试,我们稍后会讲到。但这里的想法是,对比度比率是我们评估Web应用和工具的工具之一。

颜色对比是其中之一。还有更多。特别有用的是考虑像图标这样的东西,我们每个图标都需要确保有对屏幕阅读器可见的文本。

屏幕阅读器演示

我将演示Facebook。这是写在UC Berkeley Confessions页面上的可能是有史以来最好的忏悔之一,如果你没见过,你应该读一下歌词,因为我们的一位学生在这首歌上做了非常出色的工作。

我将在这里打开Mac OS X的屏幕阅读器。当屏幕阅读器激活时,它会读取屏幕上的文本并向我们宣布内容。如果你完全失明,这将是你的主要界面。当我按Tab键切换时,它会读取内容:“Safari, confessions see Berkeley window, 4 confessions see Berkeley where concept is keyboard focus.”

作业中有关于如何为你的系统打开屏幕阅读器的说明。但很酷的是,屏幕阅读器会获取计算机屏幕上的一切,并让你能够以纯音频方式与之交互,或者如果你有视力,可以结合音频和视觉。当我按Tab键切换时,屏幕阅读器会读取Facebook页面的各个部分。

在Mac上,Control键可以静音屏幕阅读器,这是一个非常方便的快键键。Facebook在提供可访问界面方面做得非常出色。当你第一次在Facebook上按Tab键时,你会得到这个非常酷的控制栏,它提供了许多独特的工具。我可以使用Tab键在此页面的不同部分之间导航。

当我按Tab键切换时,屏幕阅读器会读取某些元素并尝试给它们文本标签。它说“combo box”,这是一个相当技术性的术语,但这意味着我可以输入并使用箭头键导航结果的东西。“Link Michael Facebook”。当我悬停在链接上时,屏幕阅读器会声明它们是链接,它会告诉我是否访问过它们,它会读取此链接中的文本。“Li collapse”,所以这很好,因为它告诉我这是一个菜单。它可以弹出,也可以折叠。我可以点击它。当我这样做时,它会展开菜单的内容。屏幕阅读器知道这是一个可以展开和折叠的东西。

屏幕阅读器查看网页的HTML标记,检查其属性以确保它们可访问。

使非交互内容可访问

在最后两分钟,我将快速介绍如何获取默认可能不可交互的内容,并添加工具使其更具交互性。

这是一个按钮,通常我可以通过按空格键来按下它。这很正常,我可以点击它,得到相同的结果。我在此页面上添加了Bootstrap。

我有另一个按钮,做同样的事情,但只使用Bootstrap。现在,当我按Tab键切换时,你会注意到中间有一个我实际上无法用Tab键切换到的按钮。如果我们看看它是如何实现的,它并没有完全按我预期的那样工作。

这里的代码使用了一个带有使其看起来像按钮的类和一个onclick处理程序的div标签。在JavaScript中,我们使用DOM API几乎可以做任何我们想做的事情。但如果我们只添加onclick处理程序而不考虑按钮可以被使用和访问的所有方式,这个东西将无法通过键盘访问,因此屏幕阅读器也无法访问。

我不期望任何人一次性学习或记住所有这些属性。但我要做的是,除了响应点击之外,我们还可以说这个东西也响应按键事件。为了使一个普通对象显示为可以用Tab键切换的东西,我们指定一个名为 tabindex="0" 的属性。这告诉浏览器,这个不是按钮或链接的东西实际上应该可以用键盘Tab键切换。当我在一个元素上有 tabindex="0" 时,当我使用Tab键浏览网页时,如果我使用屏幕阅读器逐步浏览链接,这将被读取为可以点击的东西。然后我可以使用这个名为 role="button" 的属性来表示这个东西是一个按钮,即使它是用div标签制作的。

自动化可访问性测试

如果你有机会,作业将引导你使用工具来审计网页的可访问性合规性。有一个链接指向微软的一个非常棒的扩展,名为 Accessibility Insights。你将其添加到Chrome中,点击“Fastpass”。这是伯克利的subreddit,很好,因为它有一些错误,但不多。你得到的结果列表和方式,网页上的元素可能缺少键盘处理程序之类的东西。它们可能是可点击但无法通过键盘访问的元素。你还会在这里看到颜色对比度问题、缺少替代文本的图像等。如果你有一张图片,有人在使用屏幕阅读器,图片上的 alt 属性是为该屏幕阅读器提供描述性文本的正确方式。

作业大致指导你使用Accessibility Insights。你运行一个测试。如果你在自己的应用上运行它,希望你会得到一些反馈。你可以点击它们,它会列出页面上未通过该测试的实际HTML代码。还有关于如何修复此测试、通过它的技术、为什么重要等信息的链接。

这里的想法是,除了只想编写干净的代码,我们拥有的标记在用户如何与我们的网页交互方面很重要。

总结

本节课中,我们一起学习了如何使用jQuery简化DOM操作和跨浏览器兼容性问题,掌握了通过Ajax实现异步数据交互来增强应用动态性。我们探讨了使用Jasmine测试JavaScript代码的方法,实践了前端测试驱动开发。最后,课程重点深入探讨了Web可访问性的核心原则与实践,包括颜色对比度、屏幕阅读器兼容性、键盘导航支持以及自动化测试工具的使用。构建包容性应用不仅是一项法律和道德要求,也能为所有用户创造更好的体验。

025:雅达利2600与“最奇怪的Hello World”

在本节课中,我们将探索早期家用游戏机雅达利2600的硬件架构与编程模型。你将了解到在1970年代,工程师们如何在极其有限的硬件资源(如仅128字节RAM)下,通过巧妙的硬件设计实现图形显示,并理解为何为其编写一个简单的“Hello World”程序会如此复杂。

课程概述

上一节我们介绍了软件工程的一般概念,本节中我们来看看一个具体的、具有历史意义的软硬件协同设计案例:雅达利2600游戏机。我们将从其历史背景、革命性的硬件设计出发,深入探讨其独特的“扫描线渲染”机制,并最终理解为何为其编程是一项极具挑战性的“与光束赛跑”的任务。

雅达利2600的诞生背景

在深入技术细节前,有必要了解其历史背景。雅达利公司最初凭借街机游戏《Pong》大获成功。然而,当公司试图将《Pong》集成到专用芯片(ASIC)上以推出家用版本时,他们发现竞争对手很容易模仿这一设计。

为了在家用市场保持领先地位,雅达利需要一种难以被复制的解决方案。这催生了雅达利2600,它并非第一款使用卡带的家用游戏机,但无疑是第一款取得巨大成功的产品。其核心创新在于使用了一个通用的微处理器(MOS 6502)和一套独特的、名为“电视接口适配器”(代号Stella)的定制芯片来生成视频信号。

硬件限制与设计哲学

雅达利2600的设计目标零售价约为200美元(相当于今天的700美元)。在1977年,内存价格极其昂贵,每千字节(KB)RAM的成本约为8美元。

一个简单的计算揭示了问题所在:如果采用传统的帧缓冲器(Frame Buffer)方式,即用内存的每一位对应屏幕上的一个像素,来实现一个200x160像素、每像素8位的显示,仅内存成本就高达256美元,这已经超过了整机的目标售价。

因此,雅达利2600无法负担一个帧缓冲器。其解决方案是采用一套基于寄存器的、按扫描线(Scanline)渲染的体系结构。

核心硬件架构

雅达利2600的核心组件如下:

  • 中央处理器:MOS 6502 的廉价版本,仅能寻址 8KB 的总内存空间(RAM 和 ROM 合计)。
  • 内存:仅 128 字节 的 RAM。游戏逻辑和图形数据存储在卡带的 ROM 中,最多 4KB。
  • 电视接口适配器(TIA):定制芯片,负责生成视频和音频信号。它是实现无帧缓冲器渲染的关键。

其内存映射非常简单:128字节RAM、一些硬件控制寄存器,以及卡带ROM占用的地址空间。当插入卡带时,ROM芯片的内容就直接映射到CPU的地址总线上。

“与光束赛跑”的渲染原理

要理解雅达利2600的编程,必须了解当时模拟电视的工作原理。电视显像管中的电子束从左到右、从上到下扫描屏幕,每秒绘制约60帧图像。

TIA芯片的工作方式与这种扫描过程紧密同步。程序员无法直接控制整个屏幕的图像,而是必须在每条扫描线开始绘制之前,精确地设置好一组硬件寄存器。这些寄存器定义了:

  • 该扫描线的背景颜色。
  • 该扫描线上“游戏场地”(Playfield,即静态背景图形)的样式。
  • 该扫描线上玩家精灵(Player Sprite)、子弹(Missile)等移动对象的位置和颜色。

以下是程序员必须遵循的流程:

  1. 在一条扫描线绘制期间,程序进入休眠(通过写入 WSYNC 寄存器)。
  2. 当电子束完成当前行,开始回扫到下一行起点时(水平消隐期),CPU被唤醒。
  3. 程序员有极其有限的时间(大约几十个CPU周期)来为下一条扫描线计算并设置所有相关寄存器。
  4. 设置完成后,再次休眠,等待下一条扫描线开始。
  5. 重复此过程,直到192条可见扫描线全部绘制完毕。

当电子束完成一帧画面,从屏幕右下角返回左上角时(垂直消隐期),有一段较长的“空闲”时间(约相当于30条扫描线的时间)。游戏的主要逻辑(如输入处理、物理计算、分数更新)必须在这段时间内完成。

这种编程模式被称为“与光束赛跑”(Racing the Beam)。如果代码计算太慢,未能及时为下一条扫描线设置好寄存器,就会导致图形撕裂或闪烁。

碰撞检测与游戏逻辑

TIA芯片在绘制扫描线的同时,会自动检测屏幕上不同对象(如玩家、子弹、游戏场地)之间是否发生重叠。碰撞检测的结果被存储在一个2字节的碰撞寄存器中。每帧结束后,游戏程序可以读取这个寄存器,根据哪些位被置位来判断发生了何种碰撞(例如“玩家1被子弹击中”)。

由于6502处理器没有硬件乘法、除法甚至浮点运算单元,所有游戏物理(如球的运动轨迹)都必须通过基本的加法、减法和位操作来实现,这对程序员是巨大的挑战。

实例分析:《太空侵略者》与“Hello World”

为了直观感受其能力与限制,我们来看两个例子。

《太空侵略者》家用版:这款将街机现象带入家庭的游戏,其整个程序(包括图形、声音、逻辑和所有变体模式)仅占用约 2KB 的ROM空间。这充分展示了在极端限制下编程所需的精湛技艺。

“最奇怪的Hello World”:为雅达利2600编写一个显示“Hello World”的程序异常复杂。因为“Hello World”的每个字母都是由“游戏场地”的位图构成的,而游戏场地的控制非常原始。

关键难点在于,游戏场地寄存器在水平方向只能定义20位(40像素)的图形,并且默认情况下,屏幕左右两半的图形是对称或镜像的。为了在屏幕中央显示非对称的文字(如“Hello World”),程序员必须采用一种称为“周期精确”(Cycle-Counting)的编程技巧:

  1. 在扫描线开始时,设置游戏场地寄存器为左半部分所需的图形。
  2. 精确计算电子束绘制完左半部分所需的时间(以CPU周期为单位)。
  3. 恰好的时刻,在程序运行中动态修改游戏场地寄存器的值,以便在电子束绘制右半部分时显示不同的图形(即文字的另一半)。

这要求程序员像编写硬件时序逻辑一样编写代码,确保每一条指令的执行时间都严丝合缝。正如课程演示中所见,即使一个简单的、带有颜色渐变背景和移动小球的“Hello World”程序,其汇编代码也相当冗长和复杂,核心任务就是不断地在垂直消隐期计算、在水平消隐期设置寄存器,周而复始。

总结

本节课中,我们一起学习了雅达利2600游戏机的软硬件架构。我们看到了在1970年代严苛的成本与技术限制下,工程师如何通过放弃通用的帧缓冲器,设计出与电视扫描硬件深度耦合的“扫描线渲染”架构。我们理解了为其编程的本质是“与光束赛跑”,需要在精确的时间窗口内完成计算和寄存器设置。通过分析《太空侵略者》和“Hello World”程序,我们切身感受到了在 128字节RAM无现代图形API 的环境下进行游戏开发所需的极致优化与创造力。这段历史生动地展示了软件工程不仅是关于编写代码,更是关于在特定的硬件约束下设计出巧妙、高效的解决方案。

026:第26讲 - 项目展示总结 🎓

在本节课中,我们将一起回顾UCB CS169 2019课程中多个学生团队的最终项目展示。这些团队在过去一个学期里,与真实的客户合作,运用敏捷开发方法,从零开始或基于遗留代码构建了功能性的Web应用程序。我们将看到他们面临的挑战、学到的经验以及项目的核心功能。


项目一:BJC教师追踪器 👨‍🏫

上一节我们介绍了课程背景,本节中我们来看看第一个展示的项目。

概述:该项目为“Beauty and Joy of Computing”课程开发了一个教师管理系统。该系统允许高中教师在线申请访问课程材料,管理员可以审批申请并查看相关统计数据。

核心功能
以下是该应用的主要功能点:

  1. 教师申请表:一个在线表单,用于收集教师及其学校的信息。
  2. 管理员仪表盘:管理员登录后可以验证教师申请、查看课程与学校统计信息。
  3. 自动邮件通知:当教师申请被批准后,系统自动发送包含登录凭据的欢迎邮件。
  4. 交互式地图:在地图上用图钉标记所有参与BJC项目的学校位置。

技术挑战与设计
团队遇到的主要挑战包括集成Google OAuth2和处理不同环境的数据库(开发使用SQLite,生产环境使用PostgreSQL)。他们的设计策略是先实现核心功能,再逐步完善样式和用户体验。

客户协作与团队实践
客户Michael提供了清晰的目标和反馈。团队较好地遵循了敏捷实践,包括定期站会、迭代计划会议和回顾会议。他们使用Pivotal Tracker管理用户故事并尝试结对编程。不足之处是偶尔会过度承诺功能,并且很难找到一个所有成员都有空的时间开会。

给未来开发者的建议
代码模型关系应保持简单,避免过度设计。在部署到生产环境前,需充分考虑数据库兼容性问题。


项目二:Stagebridge导师签到系统 📍

在了解了教育领域的应用后,我们转向一个社区服务项目。

概述:该项目为Stagebridge导师计划开发了一个基于地理位置验证的Web签到应用,取代了容易出错的纸质签到系统。

核心功能
以下是该应用解决的核心问题:

  1. CalNet认证登录:确保只有合法的UC Berkeley学生可以签到。
  2. 地理位置验证:通过获取用户设备的GPS位置,确保导师在实际指定的学校地点签到。
  3. 数据导出:管理员可以将签到数据导出为Excel格式。

技术挑战
最大的挑战是实现可靠且一致的地理位置获取。解决方案是放弃Rails内置服务,转而使用Google Maps API的Javascript接口。

客户协作与团队实践
客户Jasmine(一名数据科学学生)给出了明确的目标和及时反馈。团队沟通良好,但偶尔会高估迭代的工作量,导致某些功能未完成。

未来迭代计划
如果时间允许,团队希望增加数据加密、提升网站无障碍访问性、集成数据可视化工具以及添加未按时签出的通知系统。


项目三:Cal Nerds学生主管调度工具 🗓️

接下来,我们看一个用于校园组织内部管理的工具。

概述:该项目为校园组织Cal Nerds开发了一个移动友好的学生主管调度系统,用于跟踪可变和固定的工作时间,并自动计算工时总额。

核心功能
该应用的核心改进包括:

  1. 直观的日程提交界面:模仿When2Meet的交互式表格,方便主管提交时间。
  2. 邮件提醒:使用Action Mailer和Rufus Scheduler自动发送提交日程的提醒邮件。
  3. 响应式设计:确保在移动设备上具有良好的使用体验。

技术挑战
挑战包括处理开发与生产环境数据库的差异(SQLite vs. Postgres),以及测试会话哈希。

敏捷实践反思
团队在“可工作的软件”和“响应变化”方面做得很好,但在“客户协作”上可以改进,因为客户在会议间隙较难联系。

未来方向
未来需要使应用动态适应组织成员的变化,并改进UI/UX,例如开发更直观的日历界面和增加更多RSpec测试。


项目四:Hispanics in Computing交互式地图 🌎

现在,我们来看一个服务于特定社区的网络应用。

概述:该项目为Hispanics in Computing组织重新设计了网站,并新增了成员交互式地图功能,以促进社区内的联系。

核心功能
以下是实现的主要特性:

  1. 网站重构与重新设计:改善了旧网站的信息层次和视觉布局。
  2. 成员资料页:用户可以通过Google登录,编辑个人资料并选择是否在地图上公开显示。
  3. 交互式地图:使用Leaflet.js库展示同意公开信息的成员位置。
  4. Slack成员验证:通过比对Slack工作区成员列表来验证登录用户是否为组织成员。

技术挑战
主要挑战是验证用户身份。在尝试LinkedIn和Google Groups API受限制后,团队最终通过Slack API获取成员邮箱列表来实现验证。

开发流程
团队遵循了标准的敏捷工作流:客户会议 -> 迭代计划会议 -> 开发 -> 回顾会议。随着迭代进行,沟通和测试覆盖度逐步改善。

未来功能
希望未来能增加由管理员发布的博客功能、更多登录选项(如Slack直接登录)、更丰富的地图信息弹出窗口以及筛选过滤功能。


项目五:CS 370导师匹配平台 👥

许多项目都涉及连接不同的人群,这个项目也不例外。

概述:该项目基于遗留代码,为CS 370课程开发了一个连接学生与研究生导师的在线门户。

核心功能
平台实现了完整的导师匹配流程:

  1. 学生可以为特定课程请求导师。
  2. 导师可以接受请求。
  3. 双方可以协商并确定会议时间。
  4. 会议结束后,学生可以提交评价。

技术挑战
处理遗留代码是最大挑战,包括过时、硬编码的测试,以及中途需要引入新功能(如会议安排)导致的设计变更。团队通过大量自主研究和调试来解决跨浏览器兼容性等问题。

客户协作
客户Christopher(教授)的沟通直接明了,他清晰地区分了本团队与另一平行团队的任务,避免了冲突。定期会议和详细的会议记录保证了项目方向正确。

团队实践与建议
保持持续沟通和相互问责是关键。团队应避免使用多种通信工具导致信息丢失。给未来开发者的建议是:务必在充分理解遗留代码的交互逻辑后再进行修改,并加强集体代码测试。


项目六:CBVX STEM患者门户 🏥

软件工程在医疗健康领域也有重要应用。

概述:该项目为干细胞治疗诊所CBVX STEM开发了一个医患门户,用于管理患者表格、治疗历史和医患沟通。

核心功能
门户的核心是权限管理和资源关联:

  1. 差异化权限:医生和患者对病历、治疗方案等资源有不同的访问和操作权限。
  2. 资源关联:将药物、文档和治疗等相互关联但独立的资源通过“用户持有者”接口进行有效组织。

技术实现
团队使用CanCanCan gem来集中管理复杂的权限控制逻辑,这比在控制器中硬编码规则更清晰、更易维护。

团队实践
团队设定了每人每迭代完成2个故事点的目标,并通过Slack定期站会。Scrum Master在回顾会后为每人撰写评估,使同行评审更客观。不足之处是结对编程因日程冲突而实施较少。

未来展望
希望未来能改进UI设计、实现平台内部的消息系统以替代外部通信。


项目七:Citris资源中心网站维护 🔧

维护和更新现有网站是软件工程的常见任务。

概述:该项目基于遗留代码,为Citris资源中心网站添加自动化维护功能,包括资源所有者自助更新和链接健康检查。

核心功能
项目聚焦于自动化:

  1. 资源所有者界面:允许资源所有者直接编辑其资源信息,无需管理员手动更新。
  2. 计划邮件:自动发送提醒邮件(如资源信息更新提醒、失效链接通知)。
  3. 链接健康检查:定期检查数据库中的链接是否有效。

技术挑战
挑战包括理解遗留的Devise身份验证gem、实现并测试计划邮件任务(最终使用Action Mailer和Heroku Scheduler),以及在前端混合了React和ERB模板的代码库中定位功能点。

团队经验
团队在分解复杂任务和使用Pivotal Tracker方面做得很好。大量结对编程有助于理解复杂的遗留代码。未来可以尝试更多角色轮换,并更早地编写测试。

安全与性能建议
给未来开发者的重要提示是:需要加强安全性(某些管理员功能可能对普通用户可见),优化数据库结构以提升页面加载速度,并在测试邮件功能时格外小心,避免向真实客户发送测试邮件。


项目八:Audience First票务系统升级 🎭

最后一个项目展示了如何为长期运行的系统添加复杂的新功能。

概述:该项目为已开发超过10年的非营利剧院票务系统“Audience First”添加新功能,主要涉及订单评论系统的重构。

核心功能
核心变更是将订单级别的评论“下放”到票务项级别:

  • 旧模式:一个订单只有一个总体评论。
  • 新模式:订单中的每张票或每个捐赠项都可以有独立的评论(例如,注明哪位家庭成员需要特殊照顾)。

技术挑战
这是在大型生产代码库上进行的重大修改。团队必须:

  1. 仔细修改涉及订单评论的控制器和模型。
  2. 编写SQL迁移脚本,安全地修改生产数据库结构。
  3. 删除或更新过时的遗留测试代码。
  4. 在沙盒环境中充分测试数据库变更后再应用到生产环境。

客户协作
客户Fox教授非常专业,他亲自创建Pivotal Tracker故事并提供详细的技术解答。团队有时可能过于依赖他的知识,而未能充分自主探索代码库。

敏捷实践
团队坚持每周客户会议(而非双周)、经常进行结对编程并轮换项目经理角色。需要改进的是按时提交课程检查点。


项目九:Night Out/Night Off文化场所发现平台 🌃

我们以一个从零开始构建的社区平台结束本次回顾。

概述:该项目为研究生(尤其是有色人种研究生)创建了一个文化场所与活动发现平台,包含场所数据库、用户系统和评价功能。

核心功能
平台构建围绕以下核心:

  1. 场所数据库:从Excel表格导入数据,可按名称、评分和亲和力群体筛选。
  2. 用户与评价系统:使用Devise gem实现用户登录。用户可以评价场所(1-5星),并点赞/点踩他人的评价。
  3. 亲和力群体标记:帮助少数群体学生识别对他们更友好的场所。

技术挑战
挑战包括团队成员间Ruby、Gem版本不一致导致的环境问题,以及初始数据中的属性缺失或错误对评分算法造成的干扰。

敏捷协作
团队积极互助,但初期在合并代码和安排会议时间上遇到困难。客户偏好使用Google Docs进行沟通,团队需要适应这种方式。

可扩展性考虑
随着数据量增长(例如扩展到湾区以外),需要考虑性能优化。未来可能需要引入“超级用户”角色来协助管理内容和审核评价。


总结与课程结束语 🎉

本节课中,我们一起学习了九个不同领域的学生软件工程项目。从教育管理、社区服务、医疗门户到文化平台,这些项目生动展示了软件工程如何解决真实世界的问题。每个团队都实践了敏捷开发方法,经历了需求分析、技术挑战、客户沟通和团队协作的全过程。关键收获包括:清晰沟通的重要性、对遗留代码的谨慎处理、测试先行的价值,以及在承诺交付时保持现实态度。教授最后提醒大家填写课程评价,并通知下一场展示更换了教室。

这些项目不仅是课程作业,更是学生们迈向职业软件工程师的宝贵实践。感谢所有团队的精彩展示!

posted @ 2026-03-29 09:26  布客飞龙II  阅读(8)  评论(0)    收藏  举报