UNSW-COMP1531-软件工程基础笔记-全-

UNSW COMP1531 软件工程基础笔记(全)

001:JavaScript入门教程 🚀

在本节课中,我们将要学习JavaScript编程语言。我们将从它与C语言的区别开始,逐步介绍其核心语法、数据结构以及一些独特的语言特性。课程旨在帮助已有C语言基础的同学快速上手JavaScript。


概述 📋

JavaScript是一种高级、多范式、解释型的脚本语言,广泛应用于Web开发。与C语言不同,JavaScript不直接处理内存和指针,没有显式的类型声明,并且拥有丰富的内置功能。本节课我们将通过对比C语言,学习JavaScript的基本语法和核心概念。


为什么学习JavaScript?🤔

JavaScript是构建现代Web应用的核心语言。它拥有庞大的生态系统和开源库,能帮助开发者快速构建应用。学习JavaScript不仅是为了掌握一门新语言,更是为了理解如何高效地学习第二门编程语言。


JavaScript与C语言对比 🆚

上一节我们介绍了学习JavaScript的原因,本节中我们来看看它与C语言的主要区别。

以下是两种语言的关键差异:

特性 C语言 JavaScript
范式 过程式 多范式(过程式、函数式、面向对象)
类型系统 静态类型(如 int, double 动态类型(无显式类型声明)
内存管理 手动(malloc/free 自动垃圾回收
编译/解释 编译型语言 解释型语言(通过Node.js运行)
指针 支持指针运算 不支持指针

核心区别公式化描述

  • C语言函数定义: int min(int a, int b) { ... }
  • JavaScript函数定义: function min(a, b) { ... }

主要区别在于JavaScript函数没有返回类型和参数类型的声明。


运行JavaScript代码 ▶️

在C语言中,我们需要先编译(gcc),再运行可执行文件。JavaScript则通过Node.js直接解释执行。

C语言运行步骤:

gcc -o program program.c
./program

JavaScript运行步骤:

node program.js

node 是一个命令行工具,它读取.js文件中的代码并立即执行。这种“边解释边执行”的方式使得JavaScript开发流程更快捷。


变量与常量 📦

在JavaScript中,我们使用 letconst 来声明变量,而不是指定类型(如 int)。

  • let: 用于声明可重新赋值的变量。
  • const: 用于声明常量,其值不可改变。

代码示例:

let changeable = 10; // 这个值可以改变
changeable = 20;     // 正确

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_25.png)

const constant = 5;  // 这个值不可改变
constant = 8;        // 错误!TypeError

JavaScript的变量可以持有任何类型的数据,包括数字、字符串、布尔值,以及特殊的 undefinednull


字符串操作 🔤

字符串处理在JavaScript中比在C语言中简单得多。你可以使用加号(+)来连接字符串。

代码示例:

let greeting = "Hello";
greeting = greeting + " " + "World!"; // greeting 现在为 "Hello World!"
greeting += " How are you?";          // 等同于 greeting = greeting + " How are you?"

模板字符串是一种更强大的字符串创建方式,允许嵌入变量或表达式。

代码示例:

const name = "Hayden";
const age = 7;
const message = `Hello, my name is ${name} and I am ${age} years old.`;
console.log(message); // 输出: Hello, my name is Hayden and I am 7 years old.

注意:模板字符串使用反引号(`)而非单引号或双引号。


控制结构 🎛️

如果你熟悉C语言,那么JavaScript的 ifelsewhilefor 循环看起来会非常亲切。语法几乎一致,只是去掉了类型声明。

代码示例:

// if-else 语句
if (score > 90) {
    grade = 'A';
} else if (score > 80) {
    grade = 'B';
} else {
    grade = 'C';
}

// while 循环
let i = 0;
while (i < 5) {
    console.log(i);
    i++;
}

// for 循环
for (let j = 0; j < 5; j++) {
    console.log(j);
}

函数 📞

JavaScript使用 function 关键字定义函数。由于是动态类型,我们不需要指定参数和返回值的类型。

代码示例:

function add(a, b) {
    return a + b;
}

const result = add(3, 5); // 调用函数
console.log(result);      // 输出: 8

函数是JavaScript的一等公民,可以像变量一样被传递和使用。


数据结构:顺序集合 (数组) 🗃️

数组是JavaScript中最常用的顺序集合,用于存储有序的数据列表。与C语言的数组相比,JavaScript数组更加灵活和强大。

以下是创建和操作数组的基本方法:

  1. 创建数组:
    const fruits = ['apple', 'banana', 'orange'];
    

  1. 访问与修改元素:
    console.log(fruits[0]); // 输出: 'apple'
    fruits[1] = 'grape';    // 将第二个元素改为 'grape'
    

  1. 添加元素:
    fruits.push('mango');   // 在数组末尾添加 'mango'
    

  1. 获取数组长度:
    console.log(fruits.length); // 输出: 4
    

遍历数组有多种简洁的方法:

// 方法1: 传统的 for 循环
for (let i = 0; i < fruits.length; i++) {
    console.log(fruits[i]);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_76.png)

// 方法2: for...of 循环 (获取值)
for (const fruit of fruits) {
    console.log(fruit);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_78.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_80.png)

// 方法3: for...in 循环 (获取索引)
for (const index in fruits) {
    console.log(index, fruits[index]);
}

数组是“对象”,内置了许多有用的方法,如 .push(), .pop(), .includes() 等。


数据结构:关联集合 (对象) 🗂️

对象是JavaScript的关联集合,类似于C语言中的结构体(struct),但功能更强大。它用于存储键值对(key-value pairs)。

代码示例:

// 创建一个对象
const student = {
    zid: 'z1234567',
    name: 'Alice',
    mark: 85
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_90.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_92.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_94.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_96.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_98.png)

// 访问属性
console.log(student.name); // 输出: 'Alice'
console.log(student['zid']); // 输出: 'z1234567' (另一种访问方式)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_100.png)

// 修改或添加属性
student.mark = 90;       // 修改现有属性
student.grade = 'HD';    // 添加新属性

对象是动态的,可以随时添加或删除属性。

遍历对象:

for (const key in student) {
    console.log(`${key}: ${student[key]}`);
}
// 输出:
// zid: z1234567
// name: Alice
// mark: 90
// grade: HD

你也可以使用 Object.keys(), Object.values(), Object.entries() 等内置方法来处理对象。


组合使用数组与对象 🤝

在实际开发中,我们经常组合使用数组和对象来建模复杂数据。

代码示例: 一个包含多个学生对象的数组。

const students = [
    { name: 'Alice', score: 92 },
    { name: 'Bob', score: 78 },
    { name: 'Charlie', score: 88 }
];

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_116.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/8745e98bc46b158cc24032f59bc347ef_117.png)

// 找出成绩高于85分的学生
for (const student of students) {
    if (student.score > 85) {
        console.log(`${student.name} got an HD!`);
    }
}


重要注意事项与总结 🎯

本节课中我们一起学习了JavaScript的基础知识。最后,有几个关键点需要牢记:

  1. 相等性比较:在JavaScript中,使用 ===(严格相等)和 !==(严格不相等)来比较值和类型,避免使用 ==!=,因为它们会进行类型转换,可能导致意想不到的结果。
    if (variable === 10) { /* 正确 */ }
    if (variable == 10) {  /* 避免使用 */ }
    

  1. 注释:JavaScript的注释语法与C语言相同。
    // 这是单行注释
    
    /*
    这是
    多行
    注释
    */
    

  1. nullundefined:两者都表示“无”的值,但含义略有不同。undefined 通常表示变量已声明但未赋值,而 null 表示一个空值或“空对象”的引用。在初学阶段,可以先按需使用其一并保持一致性。

JavaScript是一门强大而灵活的语言,初次接触可能会觉得它比C语言“大”得多,这是正常的。不要试图一次性掌握所有细节,通过实践和构建项目,你会逐渐熟悉它。核心在于理解其动态类型、灵活的数据结构以及基于原型的面向对象思想。

002:Git单人使用 🚀

在本节课中,我们将学习Git版本控制系统的基础知识,特别是如何在一台或多台个人计算机上使用Git进行单人开发。我们将从核心概念入手,通过一系列命令实践,帮助你理解如何跟踪代码历史并与远程仓库同步。


什么是Git?🤔

上一节我们介绍了课程安排,本节中我们来看看Git是什么。

Git是一个版本控制工具。这意味着它是一个历史追踪器,使人们能够在同一代码库上并发工作。与Dropbox或Google Docs等工具不同,Git是专为程序员管理代码而设计的。

Git是一个分布式版本控制系统。这意味着每个参与者的计算机上通常都拥有完整的项目历史副本。

Git是一个命令行程序。虽然存在GitHub、GitLab等提供用户界面的网站,但理解命令行操作对于充分利用Git至关重要。

以下是三个主要的Git托管平台:

  • GitHub:目前最流行的平台,现由微软拥有。
  • Bitbucket:由澳大利亚公司Atlassian拥有。
  • GitLab:开源友好的平台。UNSW CSE使用其自托管的GitLab实例,本课程将主要使用它。

学习Git是一个持续的过程。今天你可能会快速掌握一些基本操作,但要完全理解它需要时间和实践。


核心工作流程 🔄

现在,让我们深入了解使用Git进行单人开发的核心工作流程。我们将学习如何将远程仓库的代码获取到本地,进行修改,并同步回远程仓库。

克隆仓库

首先,你需要将远程仓库(例如在GitLab上)复制到本地计算机。这个过程称为克隆。

操作命令如下:

git clone <repository_url>

执行此命令后,远程仓库的所有文件和提交历史都会被下载到本地的一个新文件夹中。

检查状态与添加更改

克隆仓库后,你可以在本地进行修改。使用 git status 命令可以查看哪些文件已被修改或尚未被Git跟踪。

当你创建新文件或修改现有文件后,需要告诉Git将这些更改纳入下一次“快照”。这通过 git add 命令完成。

操作命令如下:

git add <file_name>

你可以添加特定文件,或使用 git add . 添加所有更改。

提交更改

“添加”操作将更改放入一个称为“暂存区”的区域。当你对一组更改满意时,就可以创建“提交”。提交是项目历史中的一个永久快照。

操作命令如下:

git commit -m "描述此次提交的信息"

提交信息应简洁明了,说明此次更改的内容。

推送更改

提交只保存在你的本地仓库。为了将本地提交同步到远程仓库(如GitLab),你需要使用 git push 命令。

操作命令如下:

git push

执行后,你的本地提交就会被上传到远程仓库,其他人(或你的其他设备)就能看到这些更改。


在多台设备间同步 🖥️➡️💻

上一节我们介绍了在单台设备上的基本工作流,本节中我们来看看如何在多台个人设备(如实验室电脑和家用笔记本电脑)之间保持代码同步。

想象一下,你在学校的计算机上修改了代码并推送到了GitLab。回到家后,你需要在家用电脑上获取这些最新的更改。这时就需要使用 git pull 命令。

操作命令如下:

git pull

git pull 命令会从远程仓库获取最新的提交并合并到你的本地仓库中。它与 git push 是相反的操作。

工作模型可以概括为:远程仓库(GitLab)作为中心枢纽。你的每台设备都可以通过 git push 上传更改,并通过 git pull 下载他人的(或自己的另一台设备的)更改。


处理合并冲突 ⚠️

当你和他人(或自己的另一台设备)修改了同一文件的同一行代码,并且试图合并这些更改时,就会发生合并冲突。Git无法自动决定保留哪个版本,需要人工干预。

冲突通常发生在你尝试 git push 但被拒绝,随后执行 git pull 时。Git会尝试自动合并,但如果遇到冲突,它会标记出文件中有冲突的部分。

冲突文件看起来会像这样:

<<<<<<< HEAD
你在本地修改的内容
=======
远程仓库上的内容
>>>>>>> commit_hash

你需要手动编辑文件,解决冲突(例如,选择保留一个版本或合并两者),删除Git添加的标记符号(<<<<<<<, =======, >>>>>>>)。

解决冲突后,你需要将文件重新添加并提交:

git add <resolved_file>
git commit -m "解决合并冲突"

最后,再次执行 git push 将解决冲突后的合并结果推送到远程仓库。

最佳实践:频繁地 git pullgit push 可以最大程度地减少冲突的发生和严重程度。


命令总结 📝

本节课中我们一起学习了Git单人使用的基础。以下是本讲涉及的核心命令总结:

  • git clone <url>:将远程仓库复制到本地。
  • git status:查看工作区和暂存区的状态。
  • git add <file>:将文件更改添加到暂存区,准备提交。
  • git commit -m "message":将暂存区的更改创建为一个新的提交。
  • git push:将本地提交上传到远程仓库。
  • git pull:从远程仓库下载更改并合并到本地。
  • git log:查看提交历史。
  • git diff:查看尚未暂存的详细更改内容。

记住,Git的核心是管理一系列提交(commit)的历史。pushpull 的本质是同步这条提交历史链。通过不断练习这些命令,你将能够熟练地使用Git管理个人项目代码。

003:Git团队使用 🧑‍💻🤝

在本节课中,我们将要学习如何将Git应用于团队协作环境。我们将探讨分支、合并以及合并请求等核心概念,这些是多人高效协作开发的关键。

上一节我们介绍了Git的基本操作,如提交、推送和拉取。本节中我们来看看如何在团队中利用Git的分支功能进行并行开发。

概述:Git的树状结构

理解Git在团队中的使用,首先要明白其数据结构并非简单的线性链,而是更像一棵树。每个提交(commit)都有一个父提交,但可以衍生出多个子提交,形成分支。

核心概念:Git仓库的历史记录是一个由提交构成的树状结构,而非单一的直线。

分支:创建并行的工作流

分支可以理解为代码的“平行宇宙”。主分支(通常叫master)是稳定代码的基础。当需要开发新功能或修复bug时,可以从主分支创建新分支,独立工作而不影响主线。

以下是创建并切换到一个新分支的命令:

git checkout -b 新分支名
  • checkout 命令用于切换分支。
  • -b 参数表示创建新分支。

执行后,你的工作目录将切换到新分支,后续的修改和提交都只存在于这个分支上。

团队协作场景演示

假设团队中有Hayden和Kai两位开发者,他们需要各自开发不同的功能。

Hayden的操作(开发饮品列表)

  1. master分支创建并切换到新分支 hayden/drinks-list
  2. 在新分支上创建 drinks.txt 文件并添加内容(如“Coke”)。
  3. 进行提交(git add, git commit)并首次推送到远程仓库(git push --set-upstream origin hayden/drinks-list)。

Kai的操作(开发汽车列表)

  1. 同样从master分支创建并切换到新分支 kai/cars-list
  2. 在新分支上创建 cars.txt 文件并添加内容(如“Honda, Toyota”)。
  3. 进行提交并首次推送到远程仓库。

此时,master分支保持原样,而两个新分支各自拥有独立的新提交,形成了并行的开发线。

合并请求:代码审查与集成

当一个功能在新分支上开发完成后,需要将其合并回主分支。我们通过合并请求(Merge Request,在GitHub上称为Pull Request)来完成这个操作。

合并请求不仅是合并代码的机制,更是团队进行代码审查的协作平台。创建合并请求后,团队其他成员可以查看代码变更、提出意见或直接批准。

流程如下

  1. 开发者在GitLab/GitHub界面上,从已完成的分支向master分支发起合并请求。
  2. 填写标题和描述,说明此次变更。
  3. 团队其他成员审查代码,讨论修改,并最终批准(Approve)。
  4. 代码的作者(或得到授权者)执行合并操作,将分支代码并入master
  5. (可选)合并后删除已合并的源分支,保持仓库整洁。

合并冲突及其解决

当多人修改了同一文件的相同部分时,合并就可能产生冲突。例如,Hayden和Kai同时修改了names.txt文件的不同位置。

解决冲突的两种常见方式

  1. 在本地解决(推荐用于复杂冲突)

    • 确保你的功能分支是最新的(例如先git checkout master && git pull,再切回功能分支)。
    • master分支合并到你的功能分支:git merge master
    • Git会标记出冲突文件,手动编辑这些文件,保留所需内容,删除冲突标记(<<<<<<<, =======, >>>>>>>)。
    • 解决后,执行git addgit commit来完成这次合并提交。
    • 最后将更新后的功能分支推送到远程。
  2. 在GitLab界面解决(适用于简单冲突)

    • 如果合并请求页面提示存在冲突,可以点击“Resolve conflicts”按钮。
    • 在提供的编辑器中直接修改代码,解决冲突。
    • 提交修改到源分支。

持续同步:合并主分支到功能分支

在长期开发的功能分支上,定期将master分支的更新合并进来是一个好习惯。这可以:

  • 减少最终合并回master时出现冲突的几率和复杂度。
  • 确保你的功能基于最新的代码基础进行开发。

操作很简单:在功能分支上,执行 git merge master 即可将主分支的最新改动同步过来。

工作流总结

有效的Git团队工作流可以概括为:

  1. 基于稳定的master分支开始新工作。
  2. 为每项任务创建独立的功能分支
  3. 在功能分支上频繁提交,完成开发。
  4. 定期将master分支合并到功能分支,保持同步。
  5. 功能完成后,发起合并请求,邀请同伴审查。
  6. 通过审查后,将功能分支合并回master
  7. (可选)删除已合并的功能分支。

理想状态下,项目历史看起来像一条主线(master),不时有短线(功能分支)分出后又很快合并回来。

给初学者的建议

本节课信息量很大。如果你感到有些概念模糊或操作复杂,这完全正常。Git的熟练运用需要实践。在接下来的实验和小组项目中,你将有机会反复练习这些操作,逐步建立理解和信心。


本节课中我们一起学习了Git在团队中的核心应用:通过分支进行并行开发,使用合并请求进行代码审查与集成,以及如何处理合并冲突。记住,目标是保持主分支的稳定,让功能在独立分支上成熟后再合并回来。现在,你可以尝试在团队项目中应用这些工作流了。

004:包管理 📦

在本节课中,我们将要学习一个在现代软件开发中至关重要的概念:包管理。我们将探讨为什么需要它,以及如何使用 Node.js 的包管理器(npm)来安装和管理他人编写的代码库,从而构建更强大、更高效的应用程序。


概述

当我们编写软件时,不可能所有代码都自己从头写起。就像在 C 语言中会使用 #include <stdio.h> 一样,在 JavaScript 中,我们也需要利用他人编写好的、经过测试的代码库(或称为“包”、“模块”)。包管理器就是帮助我们查找、安装、更新和管理这些外部代码库的工具。本节课的核心是理解 npm 的工作原理及其在项目协作中的重要性。


为什么需要包管理?

上一节我们介绍了 JavaScript 的基础语法。本节中我们来看看一个现实问题:如何使用他人编写的代码。

有时,我们希望使用自己未编写的代码。例如,我们可能想使用一个 JavaScript 库,但这个库并非由我们创建。互联网上的其他人编写了这个库。一个具体的例子是:如何验证一个日期(如 2022年2月29日)是否有效?虽然可以自己编写逻辑判断闰年,但如果有人已经完成了这项工作,我们直接使用岂不更好?

事实上,确实有人编写了一个名为 date-fns 的库来解决这个问题。在 C 语言中,类似 #include <math.h> 的标准库是预装在系统中的。但在 JavaScript(Node.js)环境中,大多数第三方库并未预装,我们需要一种方法来下载和管理它们。

考虑以下代码,它试图使用 date-fns 库中的 isValid 函数:

import { isValid } from 'date-fns';

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/64d3695b59b7d22417b2e43b2d82445f_11.png)

function dateIsValid(year, month, day) {
    return isValid(new Date(year, month, day));
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/64d3695b59b7d22417b2e43b2d82445f_13.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/64d3695b59b7d22417b2e43b2d82445f_14.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/64d3695b59b7d22417b2e43b2d82445f_16.png)

console.log(dateIsValid(2022, 14, 2)); // 月份14显然无效

如果直接运行这段代码,你会遇到一个错误:Cannot find package 'date-fns'。这是因为计算机上并未安装这个库。


解决方案:使用 npm

如何解决上述问题?我们需要将 date-fns 库安装到我们的计算机上。为此,我们将使用 npm(Node Package Manager)。

当你安装 Node.js 时,npm 会随之一起安装。它本质上是一个命令行工具,主要功能有两个:

  1. node: 用于运行 JavaScript 代码。
  2. npm: 用于管理 JavaScript 模块(即“包”)。

几乎所有你可能想用的 JavaScript 库都存放在一个名为 npmjs.com 的中央仓库中。你可以在这里搜索任何功能的包,例如“天气API”、“密码生成器”或“货币汇率”。


创建并管理一个 npm 项目

要使用 npm 安装和管理包,我们通常需要在一个特定的“项目”文件夹中进行操作。以下是创建一个 npm 项目并安装包的基本步骤:

  1. 初始化项目:在一个空文件夹中,运行 npm init 命令。这会引导你填写项目信息(如名称、版本),并最终生成一个名为 package.json 的核心配置文件。
    mkdir my-project
    cd my-project
    npm init -y # -y 参数表示接受所有默认选项
    

  1. 安装包:使用 npm install <package-name> 命令来安装特定的包。例如,安装 date-fns

    npm install date-fns
    

    运行后,你会注意到两件事:

    • 当前目录下出现了一个 node_modules 文件夹,里面存放着下载的 date-fns 库及其所有代码。
    • package.json 文件中多了一个 “dependencies” 部分,里面记录了 date-fns 及其版本号。
  2. 运行代码:现在,再次运行之前报错的 JavaScript 文件,代码应该可以成功执行,并输出日期验证结果。


理解项目结构

一个典型的 npm 项目包含几个关键文件,理解它们的作用至关重要:

  • package.json项目的蓝图或食谱。它描述了项目的基本信息(名称、版本)以及依赖项列表(即需要哪些“食材”)。它不包含实际的代码,只包含指令。
  • node_modules存放所有已安装“食材”的仓库。当你运行 npm install 时,所有依赖包的实际代码都会被下载到这个文件夹中。此文件夹通常非常庞大,因此我们从不将其提交到 Git 版本控制中。
  • package-lock.json确保依赖树确定性的锁文件。它记录了每个依赖包的确切版本号以及其子依赖的版本,确保团队中所有成员在任何时候安装都能得到完全相同的依赖树。此文件需要提交到 Git。

以下是它们如何协同工作:

  1. 你将 package.jsonpackage-lock.json 提交到 Git。
  2. 你的队友克隆项目后,只需运行 npm install
  3. npm 会读取 package.json 中的依赖列表,并结合 package-lock.json 的精确版本信息,自动下载所有必需的包到本地的 node_modules 文件夹中。
  4. 这样,所有人的开发环境就完全一致了。


依赖的依赖(嵌套依赖)

一个包本身可能依赖于其他多个包,这就形成了依赖树。例如,你安装的包 A 可能依赖包 B 和 C,而包 B 又依赖包 D。当你运行 npm install A 时,npm 会智能地解析并下载整个依赖树中的所有必要包。

这也是为什么 node_modules 文件夹可能变得非常大的原因。一个简单的库背后可能拖带着数十甚至数百个间接依赖。


自定义脚本

package.json 还有一个强大功能:定义自定义脚本。这允许你将常用的命令行操作封装成简单的命令。

例如,在 package.json“scripts” 部分添加:

{
  “scripts”: {
    “start”: “node app.js”,
    “test”: “echo \“Running tests…\” && npm run lint”,
    “lint”: “eslint .”
  }
}

然后,你就可以通过 npm run <script-name> 来执行这些命令:

npm run start # 等同于运行 `node app.js`
npm run test  # 会依次执行 echo 命令和 lint 脚本

这在自动化测试、代码检查、项目构建等场景中非常有用,我们将在后续课程中深入探讨。


总结

本节课中我们一起学习了 包管理 的核心概念。我们了解到:

  1. 使用他人编写的代码库(包)是提高开发效率的关键。
  2. npm 是 Node.js 生态系统中管理这些包的标准工具。
  3. package.json 文件是项目的配置清单,记录了所有依赖。
  4. node_modules 文件夹存储所有已安装包的实际代码,不应提交到 Git。
  5. 通过 npm install,我们可以轻松地安装依赖并确保团队环境的一致性。
  6. 依赖之间会形成复杂的树状结构。
  7. 可以利用 package.json 中的自定义脚本简化开发流程。

掌握包管理是进行现代 JavaScript 和协作开发的基础。在接下来的课程中,我们将利用这些知识来构建更复杂的应用程序。

005:多文件与导入 📚

在本节课中,我们将学习如何在JavaScript项目中处理多文件代码,以及如何在不同文件之间导入和导出功能。这是构建大型、可维护软件项目的基础。

上一讲我们介绍了使用NPM管理外部库。本节中,我们来看看如何组织我们自己的代码文件。

与C语言的简单对比 🔄

在C语言中,我们使用 #include 来引入像 stdio.h 这样的内置库。JavaScript也有类似的内置库,但导入方式不同。

Node.js自带了一系列内置模块,无需通过NPM下载即可使用。例如,fs 模块用于文件操作,path 模块用于处理文件路径。

在C语言中,#include 指令会在预处理阶段将库文件内容“复制粘贴”到当前文件中。而在JavaScript中,导入更加明确:我们从一个模块中获取一个特定的对象或函数。

代码示例:导入 path 模块

import path from 'path';
console.log(path.resolve('./m1'));

这段代码导入了 path 模块,并使用其 resolve 函数来获取一个路径的完整绝对路径。你可以将其理解为在命令行中先 cd 到该目录,再执行 pwd

导出我们自己的代码 📤

当我们编写自己的函数并希望在另一个文件中使用时,就需要用到导出和导入。

以下是一个简单的例子。假设我们有一个文件 2.2_split_before.js,其中定义了一个函数:

代码示例:定义并导出一个函数

// manyString 函数:将字符串重复指定次数
function manyString(repeats, str) {
    let result = '';
    for (let i = 0; i < repeats; i++) {
        result += str + ' ';
    }
    return result;
}

// 导出这个函数,使其可用于其他文件
export default manyString;

export default 关键字表示这个文件默认导出一个东西(这里是 manyString 函数)。

导入我们自己的代码 📥

在另一个文件中,我们可以导入并使用这个函数。

代码示例:导入并使用函数

// 从同目录下的 ‘2.2_split_before.js’ 文件中导入 manyString 函数
import manyString from './2.2_split_before.js';
console.log(manyString(5, 'hello'));

运行这个文件,它将输出重复5次的“hello”。

如果被导入的文件位于不同目录,需要在导入路径中指明。例如,../ 表示上一级目录。

导出多个功能 🧩

通常,一个文件会包含多个有用的函数。这时,我们可以使用“命名导出”来导出多个项目。

以下是导出多个函数的方法:

代码示例:命名导出多个函数

function manyString(repeats, str) { /* ... */ }
function addBrackets(str) { return `[${str}]`; }

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/30048eb94109afe9681ad3efd8cd0124_45.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/30048eb94109afe9681ad3efd8cd0124_47.png)

// 使用花括号 {} 进行命名导出
export { manyString, addBrackets };

注意,这里没有使用 default 关键字。

导入多个功能 🧠

相应地,在导入时,也需要使用花括号来指定要导入的具体项目。

代码示例:导入多个命名导出

// 从指定文件导入多个函数
import { manyString, addBrackets } from './2.2_multi_export_lib_p2.js';

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/30048eb94109afe9681ad3efd8cd0124_55.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/30048eb94109afe9681ad3efd8cd0124_57.png)

let b = addBrackets('Hello');
let result = manyString(3, b);
console.log(result); // 输出: [Hello] [Hello] [Hello]

你必须使用导出时定义的准确名称进行导入。

默认导出 vs. 命名导出 ⚖️

现在我们来深入理解两种导出方式的区别和选择。

默认导出 (export default)

  • 一个文件只能有一个默认导出。
  • 导入时可以为其指定任意名称。
  • 公式: export default thing; -> import anyName from ‘./file.js’;

命名导出 (export { thing1, thing2 })

  • 一个文件可以有多个命名导出。
  • 导入时必须使用导出时的确切名称(但可以通过“别名”重命名)。
  • 公式: export { thing1, thing2 }; -> import { thing1 as newName1, thing2 } from ‘./file.js’;

一般来说,更推荐使用命名导出,原因如下:

  1. 可扩展性:未来添加新的导出项时,无需改变已有的导入方式。
  2. 明确性:导入时必须使用准确名称,代码意图更清晰,有助于工具(如IDE)提供更好的支持。
  3. 避免命名冲突:虽然默认导出可以重命名,但这有时会让代码更难以理解,尤其是当它偏离了社区或库的通用命名约定时。

编程中一个常见的误区是为了“聪明”或“简洁”而引入自定义的快捷方式或重命名,这往往会降低代码对团队中其他成员(或未来的你)的可读性。清晰和遵循惯例通常比微小的便利更重要。

关于代码风格与效率的提示 💡

当需要从同一个模块导入很多项目时,为了保持代码整洁,可以将其分行书写:

代码示例:多行导入

import {
    functionA,
    functionB,
    constantC,
    ClassD
} from './myModule.js';

关于代码风格,我们将在后续课程中详细讨论。现代JavaScript引擎非常智能,通常不需要为了微小的性能优化而牺牲代码的清晰度。始终将代码的人类可读性放在首位。

总结 📝

本节课中我们一起学习了JavaScript中多文件项目的组织方式:

  1. 使用 import ... from ‘module’ 来导入内置模块或第三方库。
  2. 使用 export default 在文件中导出一个主要功能。
  3. 使用 export { a, b, c } 在文件中导出多个功能。
  4. 使用 import { a, b } from ‘./file.js’ 来导入其他文件中的命名导出。
  5. 理解了默认导出和命名导出的区别,并认识到在大多数情况下,命名导出是更可扩展和明确的选择。

掌握这些导入/导出机制是构建模块化、易于维护的JavaScript应用程序的基石。

006:动态验证 🧪

在本节课中,我们将深入探讨软件工程中最重要的主题之一:动态验证。这是关于确保软件按预期工作、行为安全的一系列实践。我们将学习如何通过编写和运行测试来验证软件,理解不同编程语言在防止错误方面的特性,并探讨如何以正确的方式进行测试以获得更好的结果。

上一节我们介绍了课程概述,本节中我们来看看验证的基本概念。

验证的概念

验证是一项活动,本质上是将系统与其需求进行比较。它检查系统是否按照我们所说的方式工作。例如,如果你建造一张桌子,其设计要求是承重100公斤,那么验证这张桌子是否正确的方法就是放上100公斤的物品进行检查。

在软件领域,糟糕的软件代价高昂。错误和问题可能会在后期给公司带来巨大的损失。在某些领域,如飞机制造、汽车防抱死制动系统或医疗设备中,软件必须正常工作,不能有任何妥协,因此验证至关重要。

在代码层面,我们通常关注两种验证领域。

  • 静态验证:在代码执行之前进行的测试,有时称为编译时检查。例如,编译器在编译代码时会检查变量是否定义、数组访问是否越界等问题。
  • 动态验证:在代码执行期间进行的测试。这就是我们通常所说的“测试”——编译代码(如果是编译型语言)然后运行它,对运行中的代码进行实际测试。

本节课的重点是动态验证。

所有这些实践都是为了最终提高软件安全性。正确性(确保软件做它该做的事)和验证是同一目标的不同方面。软件安全性与软件安全性不同:安全性是针对故意滥用的防护,而安全性是针对意外滥用的防护。

当软件的设计或实现允许出现意外或非预期的行为时,软件就会变得不安全。例如,在C语言中读取未初始化的内存或写入数组边界之外,都是运行时可能发生的安全问题。

并非所有错误都是关键的安全问题。例如,YouTube Shorts的一个小bug可能只会短暂影响用户体验。但我们的目标是尽可能让软件更安全。

软件安全性的例子

让我们看一些软件安全性的具体例子。

  • 内存安全性:C语言不被认为是内存安全的编程语言,而JavaScript是。这是因为JavaScript阻止访问未初始化或未分配的内存,你也不能进行指针运算。JavaScript在运行时动态管理内存,在用户和底层内存之间创建了一个抽象层。而在C语言中,没有对数组访问进行边界检查,如果你尝试设置array[6] = 4(对于一个长度为5的数组),C语言会允许这样做,并可能导致崩溃。这并不意味着C语言更差,只是优先级不同:通常,在运行时添加安全检查会增加开销和性能成本。因此,C语言在运行时通常比JavaScript更快。

本节课主要讨论动态验证,其中很大一部分内容是关于内存安全性的,即我们如何确保软件在运行时(代码执行期间)的每种情况下都是安全的并能正常工作。

在深入动态验证之前,我们先简要了解一下静态验证,我们将在下周详细讨论它。

静态验证通常被认为是一种更健壮和可靠的测试形式。理想的软件世界是:如果你写的代码能成功编译,那么它就保证是完美且无错误的。但问题是,你能静态完成的事情是有限的。例如,在编译时,你无法知道从命令行读取的用户输入是什么。因此,我们在这里能做的事情是有限的。

动态验证

动态验证是在软件执行期间进行的验证,这通常就是我们所说的“测试”。我们通常认为测试分为两个领域:测试非常小的东西(小规模测试)和测试非常大的东西(大规模测试)。

测试的一个重要理念是:测试显示的是缺陷的存在,而不是缺陷的不存在。这是测试如此重要的原因之一。理论上,可能存在无数个潜在的缺陷。每次你编写一个测试,本质上都是在试图发现一个特定的缺陷。如果该缺陷存在,测试就会将其暴露出来。编写详尽的测试来证明软件中没有缺陷是非常困难的,这也是为什么所有大型科技公司仍然会不断出现问题。

我们无法静态测试所有东西,因为在程序运行时总有一些无法在编译时预测的意外情况会发生。一个很好的例子是:如果你的代码耗尽了磁盘空间或内存怎么办?你无法在编译时发现这个问题,只能在运行时捕获它。这就是我们需要这些测试的原因。

虽然测试显示了程序如何是正确的,但它的另一个长期好处是:当我们编写更多测试时,我们就拥有了对软件的检查点。软件总是在变化和更新,当你更新它时,你可以持续运行这些测试。如果测试通过,你就知道软件没有倒退。一个常见的软件退化方式是:你在没有测试的情况下编写软件,它通过一些试错和手动调试工作了。但一年后,有人尝试修改一些东西,看起来一切正常,但实际上已经破坏了某些功能,并且需要一段时间才能发现,因为你没有一套好的验证方法来确保一切仍然正常工作。

小规模测试与大规模测试

我们之前提到了小规模测试和大规模测试。

  • 小规模测试(单元测试):通常指测试非常小的、独立的部分。一个很好的例子是测试一个函数是否工作。例如,如果你构建一个像Airbnb这样的网站,小规模测试就是深入代码,检查那些可能只有5、10、20行的小函数,确保它们的行为符合预期。这种测试往往非常透明,不会过于抽象地看待你的代码。它更关注具体的实现细节。
  • 大规模测试(黑盒测试):这种方式我们看待系统更加抽象。黑盒测试是指我们可以在只知道其输入和输出的情况下测试某个东西,而无需知道它是如何工作的。这类测试可以分为不同层次,如模块测试、集成测试、系统测试,它们的抽象级别逐渐提高。

在本课程中,由于项目和实验规模较小,很难区分所有这些测试的细微差别。但在像Facebook这样的大型软件系统中,你可以对代码中的特定函数进行单元测试,然后对“消息保存”模块进行模块测试,再进行集成测试以查看发送和回复消息是否正常,最后进行系统测试来检查整个Facebook Messenger。这些定义并非物理定律,只是帮助我们理解测试在不同抽象层次上进行的方式。

从简单测试到测试框架

假设我们有一个函数 getEven,它接收一个数字数组,返回其中的偶数。我们如何测试它?一个简单的方法是添加一些 console.log 语句,手动检查输出。

这种方法的问题是:你必须用眼睛手动检查,没有程序化的方式来确保它工作。你仍然需要进行一些手动检查。

一个更聪明的方法是:调用函数,将结果与另一个数组进行比较,如果不匹配则打印错误信息。这样,当你运行程序时,如果没有得到预期的输出(这里没有听到错误提示),你就会知道有问题。但这里又遇到了另一个问题:在JavaScript中比较两个数组,即使它们有相同的值,也可能因为它们是不同的对象而返回 false。解决这个问题的其中一个方法是先将数组转换为字符串再比较。

但这里的问题在于,打印错误或目视检查输出是调试的方法,而不是测试。即使使用 if 语句更接近测试,但问题是你仍然依赖于“运行程序时是否打印出任何字符串”来判断。如果运行测试本身也会打印东西,情况就会变得复杂。因此,这种方法无法很好地扩展。

我们需要一种可扩展的测试方法。在探讨可扩展性之前,让我们先看看黑盒测试,因为这是很多内容的基础。

黑盒测试

我们进行的大多数测试都依赖于测试抽象的概念。测试抽象只是一种花哨的说法:有时我们会在不知道其工作原理的情况下测试某个东西。这对于函数来说有时是正确的。例如,你可能在C语言中使用 printf 函数,你知道给它什么,也知道它会做什么,但你实际上并不知道它是如何工作的。因此,我们关注问题或函数的高层理解,而不担心底层细节。

当我们以抽象的方式看待和测试系统时,我们将其视为黑盒。我们称之为黑盒,是因为你看不到盒子内部。你测试一个你不知道其内部工作原理的东西。

有趣的是,这允许我们在知道代码如何工作之前就为其编写测试。让我们看两个例子:removeVowels(移除元音)和 factorial(阶乘)函数。我们还没有为它们编写代码,但我们不需要知道它们如何工作就可以为它们编写测试。

我们可以为 removeVowels 编写测试:输入 "ABCDE",期望输出 "BCD";输入 "FROG",期望输出 "FRG"。我们也可以为 factorial 编写测试:输入 3,期望输出 6;输入 5,期望输出 120

这是以黑盒方式进行测试,因为我们是在测试它是否按描述那样行为,而不需要理解它如何实现该行为。这非常强大。它帮助你编写简单的测试,意味着你不必担心一切如何工作。最重要的是,它帮助你在编写代码之前编写测试。

在编写代码之前编写测试至关重要。原因是:当你先编写代码再为其编写测试时,你会带有一定程度的偏见。你基于对代码工作原理的理解来编写测试,因此你会在无意中倾向于只测试那些你认为会工作的东西,因为你 vested interest(既得利益)是让代码工作。而先写测试则可以避免这种偏见,让你专注于需求本身。

使用测试框架扩展测试

超越 console.logif 语句的可扩展测试是可能的,这需要通过测试框架来实现。在JavaScript中,一个非常流行的测试框架叫做 Jest

Jest 是一个通过 npm 安装的框架。Jest 测试代码看起来有点像 JavaScript,但有一些特殊的结构。例如,你可以使用 describe 来分组测试,使用 test(或 it)来定义单个测试用例,使用 expect 来断言某个值应该等于什么。

假设我们有一个导出 removeVowels 函数的文件。要使用 Jest 进行测试,我们需要先安装它:npm install --save-dev jest--save-dev 标志表示这是一个开发依赖,意味着这个库在开发时需要,但在生产环境运行时可能不需要。

然后,我们创建一个测试文件,通常命名为 *.test.js。在这个文件中,我们导入要测试的函数,然后使用 Jest 的语法编写测试。我们可以为 removeVowels 编写多个测试用例,例如:测试没有元音的字符串、测试只有元音的字符串、测试以元音开头或结尾的字符串等。

编写完测试后,我们可以运行它们。Jest 本身是一个可执行程序,安装在 node_modules/.bin/ 目录下。我们可以直接运行 ./node_modules/.bin/jest [测试文件]。更方便的是,我们可以在 package.json 文件的 scripts 部分添加一个命令,例如 "test": "jest source"。这样,我们就可以通过运行 npm run test 来执行测试了。

当我们第一次运行测试时,它们可能会全部失败,因为函数还没有实现。这没关系,这只是一个必要的步骤。测试是完整的(它们覆盖了我们感兴趣的所有边界情况),但它们还没有通过。然后,我们开始实现 removeVowels 函数。随着我们完善函数,再次运行测试,通过的测试会越来越多。测试帮助我们识别实现中的错误(比如我们使用了 replace 而不是 replaceAll)甚至测试本身中的错误(比如打错了期望值)。最终,所有测试都通过,我们就对代码的正确性有了信心。

Jest 更强大之处在于,它可以自动发现测试文件(默认寻找 *.test.js 文件),支持嵌套的 describe 块来组织测试,并且可以在测试前后运行设置和清理代码。

关于测试结构的一般建议是:最外层的 describe 描述测试的领域,内层的 describetest 用于更具体的场景。每个测试应该只测试一件事(但这不意味着一个测试里不能有多个 expect 断言,只要它们都在测试同一行为即可)。理想情况下,如果一个测试失败,它应该只对应一个行为问题。

契约式设计

在测试或实现函数时,有几种思考方式。一种是非常注重网络安全的方式,即防御性编程。例如,对于 removeVowels 函数,你预期任何东西都可能被传入,因此你测试字符串、数字、数组、对象等各种输入。

另一种方法是契约式设计。契约式设计是指我们只根据施加在它上面的约束来工作或测试。例如,如果 removeVowels 函数规定输入是一个非空字符串,返回是另一个字符串。那么,根据你的团队和编程设置,你可以说我们的输入就是一个非空字符串。因此,你只需要为此进行测试和准备。

这是否是坏实践?这取决于。这个函数可能只被其他已经检查过输入为非空字符串的函数调用。那么在这个更大的软件系统上下文中,你在这里进行测试可能是完全多余的。沟通和对问题的理解在这里非常关键。

在COMP1531中,如果我们告诉你“这个函数接收一个字符串”,那么是的,你需要检查所有可能的字符串边界情况和变体。但你不必检查它是否是一个数字,你可以假设它就是数字。

本节课中,我们一起学习了动态验证的核心概念。我们探讨了验证的重要性、软件安全性与安全性的区别、小规模测试与大规模测试(黑盒测试)、如何从简单测试过渡到使用 Jest 测试框架,以及契约式设计的思想。掌握这些知识将帮助你在未来的项目和工作中编写更健壮、可维护的软件。

007:团队协作 🧑‍🤝‍🧑

在本节课中,我们将要学习如何作为一个团队进行协作。我们将探讨敏捷开发的核心哲学,并介绍一些实用的团队协作实践,例如站会、任务板和冲突处理。这些知识将帮助你更有效地与团队成员合作,共同完成软件工程项目。


上一部分我们深入探讨了代码相关的主题。本节我们将转向一个不那么“代码密集”的讲座。本课程的前几周内容非常侧重于编程,但课程的后半部分则更偏向理论。本次讲座就属于后者,主题是“团队协作”。

这部分内容属于“学习如何合作”的主题,例如之前学习的Git。但本次讲座更侧重于“人”的层面,即如何作为团队成员一起工作,而不仅仅是代码层面的协作。理解这一点的重要性显而易见。

我们将讨论敏捷实践、站会、冲刺、任务板、会议和会议纪要等概念。这里有很多内容要讲。

我想首先说明的是,关于团队合作,我可以谈论很多。但根据我的经验,对于你们大多数人来说,这可能是第一次真正意义上在工程项目中与他人合作。如果讲得太抽象,效果可能不佳。这有点像在你从未开过飞机的情况下,只听一小时的幻灯片讲解如何飞行。直到你真正动手实践并犯过一些错误后,才能更好地理解这些概念。因此,我主张在课程中先打好基础,后续课程再深入探讨更细致的内容。我们今天会讨论很多,但请理解,这并非一个完整的、面面俱到的教程,而是在你当前学习阶段最实用的内容。

首先,我们来谈谈敏捷。

什么是敏捷? 🏃

你可能听说过“敏捷”这个词。我们经常听到“我们是一家敏捷公司”或“我们采用敏捷方法”等说法。我们需要理解它与计算领域的关系。

如果你听过更详细的介绍,通常会听到“冲刺”或“Scrum Master”等术语。这些是关于如何以敏捷方式构建团队的具体实践框架,就像足球队有各种位置一样。虽然这种框架化的方法有其价值,但我们更想探讨的是支撑敏捷方法论背后的哲学和原则。

敏捷开发本质上是一种哲学。一旦深入到冲刺、Scrum Master等具体实践,就进入了基于该哲学的具体方法。我们更关注哲学本身,而非某个特定的实践解读。

有一个网站叫 agilemanifesto.org,它概括了敏捷开发的核心信条,总结为以下四点:

  1. 个体和互动高于流程和工具。
    • 这意味着:与团队成员的良好沟通、讨论和协商,比精通GitLab等工具如何运作更重要。
  2. 可工作的软件高于详尽的文档。
    • 这个观点的核心是,我们始终希望软件能够工作。虽然完善的文档很重要,但大多数情况下,我们更关心软件能否运行。很多软件的“半衰期”很短,可能在12个月内就被修改、移除或重写,因此过分追求文档有时并不划算。
  3. 客户合作高于合同谈判。
    • 这实际上是说,在构建软件时,不应该只是在项目开始时与客户定好计划,然后埋头执行一年。而是需要与客户保持持续的对话,因为需求会变化,人的认知也会更新。
  4. 响应变化高于遵循计划。
    • 这与上一点相关,但范围更广。即使是团队计划在未来两周内完成的任务,也可能会发生变化。可能会出现需要修复的Bug,或者发现某些任务比预想的更复杂。团队不能过于执着于原定计划,需要保持适应性。

这些就是敏捷哲学的核心。它是一套用于指导各种不同流程的哲学和文化。具体采用哪些流程,在某种程度上取决于团队自身。

虽然这些流程会因团队和组织而异,但这些哲学和原则在大多数情况下是相通的,除非你开发的是非常特殊的软件。敏捷方法非常适用于大多数注重速度和客户满意度的软件项目。

人们经常讨论敏捷与瀑布模型。瀑布模型是经典的软件开发生命周期:从需求开始,依次经过设计、实现、测试,最后交付。这很像土木工程中建造大楼的过程:先规划蓝图,然后建造,最后测试验收。

软件工程也倾向于以类似的方式推进,只是我们不是线性地走完一次,而是不断地循环这个过程,每天、每周、每月都在进行需求分析、设计、实现和测试。这就是我们在课程开始时看到的软件工程生命周期。

所以,敏捷与瀑布并非截然不同。大多数项目无论如何都会迭代。关键在于节奏和周期。软件可以工作在非常紧凑的周期内,这是其他行业难以做到的。敏捷的核心思想是:我们理解传统工程世界(如花几年时间建楼)的模式,但我们的目标是应用这些哲学,帮助我们尽可能快地进行计划、构建、测试和发布。游戏的目标是在构建优秀软件的同时,尽可能快地迭代。

敏捷的意义不仅在于“更快地构建软件”(虽然确实如此),还在于上述第三点提到的“客户合作”。软件工程与其他工程领域的一个巨大不同在于,它从未有过如此紧密的消费者互动。你可以快速地从Netflix切换到Disney+。因此,敏捷的很多内容也是为了帮助建立这种紧密的客户关系,因为快速构建才能快速吸收反馈。

我说过我们会多谈原则,少谈流程。但有一个流程值得讨论:冲刺。

理解冲刺 ⏱️

冲刺是我们可以在这个课程中大致接触的一个概念。本质上,冲刺是一段固定的时间(例如一周或两周),你在这段时间内设定一系列要完成的任务。

冲刺的关键在于它是时间固定,而非范围固定。这与你通常的做法可能不同。通常,你和朋友可能说“我们要造一张桌子”,然后一直做到完成为止。而在冲刺中,终点是时间(例如一周),而不是“桌子造好”。一周结束后,无论桌子是否造好,你们都会坐下来计划下一周要做什么。

这个概念并不复杂,但它有助于你始终拥有一个检查点。在任何工程项目中,内省和反思时刻都非常重要。对于软件项目也是如此。

这意味着你和你的团队应该每周开会一次(我建议正式会议每周两次,但至少一次)。重点是,即使大家这周什么都没做,也要开会,并计划下周如何改进。否则,很容易陷入“我们做得不够,所以没必要开会”的陷阱,这对于忙碌的学生来说是一个很糟糕的配方。

在本课程中,我们不要求你们如此严格地执行,你们可以将其理解为一种理念。确保每周开会一次即可。“冲刺”这个概念在你们进入职场后可能会更加相关。


上一节我们介绍了敏捷和冲刺的理念。接下来,我们看看一些具体的团队协作实践。

团队协作实践 🤝

有几个与团队协作相关的事情需要讨论:如何保持同步、如何跟踪任务、有哪些基本实践,以及如何处理冲突。

首先,保持同步。

站会

站会是源自(或至少因敏捷而流行)的一个想法。理想情况下,团队应该每天快速开会,时间很短(3到15分钟),并回答几个问题:

  • 自从上次见面后,我做了什么?
  • 我遇到了什么问题?
  • 在下次见面之前,我打算做什么?

在我看来,这有两个非常有用的目的:

  1. 迫使你自我检查。即使你某天没有产出,大声说出来也能帮助你跟踪进度,避免陷入自我欺骗。
  2. 向队友发出信号。如果他们听到你连续几天卡在同一个问题上,可能会主动提供帮助。

典型的站会时间通常在上午10点到12点之间。如今,特别是在新冠疫情之后,异步站会开始流行。即团队不在同一时间开会或通话,而是在每天上午的一个固定时间窗口(例如8点到10点)内,在聊天频道(如MS Teams)中打字汇报。这对于大型团队或时间难以协调的团队(如学生团队)很有效。

但异步站会也有缺点:如果出现问题,很难及时解决;也更容易被遗忘,因为缺乏每日固定的仪式感。

对于本课程的项目,我们主要期望你们定期同步。如果你们有纪律每天进行,平均表现会更好。但更可能的情况是,每周在实验课上见面一次,然后在周中通过在线方式再进行一两次简短的同步。关键是在这个短期项目(约9周)中,不要超过48小时没有一次集体沟通。不要因为“没什么可谈的”就跳过会议,坚持出席和沟通本身就是一种重要的纪律和反思。

周会

周会与每日站会不同。周会通常是确定方向的地方,类似于规划冲刺。你们可能会做笔记(会议纪要),记录讨论内容。一个好的方法是记录这些笔记(课程规范中也要求了)。

一个典型的COMP1531小组周会可能在10分钟到1小时之间(通常不会太长,因为项目规模有限)。会议结构通常很简单:如何分解工作?本周做什么?然后就去执行。可以像下图所示这样记录简单的笔记,详细程度以一年后你还能看懂当时在讨论什么为准。


保持同步后,我们需要有效地跟踪任务进展。

任务跟踪

互联网上有很多工具可以帮助跟踪任务,如Trello、Jira。为了简便,本课程一般推荐使用 GitLab任务板,因为它开箱即用,对于本项目的规模来说完全足够。

你可以通过进入项目的 Issues -> Boards 找到任务板。一个任务板通常由若干列组成,这是一种简单的看板。每张卡片代表一个任务(或“票”),包含谁在做、做什么、当前状态等信息。列就代表“当前状态”部分。

在GitLab中,你可以看到“Open”和“Closed”列。你可以添加更多列,例如“进行中”。具体操作是:在项目中创建新的标签(Label),例如“In Progress”,然后将其添加到看板作为一列。

团队可以这样使用:创建一个新任务(例如“为所有函数添加存根”),然后将其分配给某个成员(如Y)。Y本周可能有五六个任务。当Y开始处理某个任务时,就将其拖到“进行中”列;完成后,再拖到“已完成”列。

通常,最简单的看板有三列:“待办”、“进行中”、“已完成”。大型项目可能在最开始还有一个“待办项(Backlog)”列,用于存放那些暂时不做但未来可能做的任务。


最后,我们介绍一种有趣的协作方式:结对编程。

结对编程

结对编程是一种极佳的团队协作方式,即两个程序员在一台电脑上工作,共用一套键盘(不是四只手同时操作),轮流编写代码并坐在一起讨论。

在结对编程中,负责打字的人专注于代码实现,而另一个人则可以从更高的层面思考,例如考虑更多的用例、整体设计等。这就像一个人割草,专注于操作机器,而另一个人观察整个草坪,指出哪里还需要修剪。

这种方式可以产生很好的讨论效果,我强烈建议你们尝试。当然,在新冠疫情后,远程进行结对编程可能更具挑战性,因为容易分心。


掌握了协作实践,我们还需要学会如何处理团队中不可避免的冲突。

处理冲突 🛡️

人际关系是美妙而复杂的。你的COMP1531团队成员也不例外。我们需要找到管理冲突的方法。这里有几个场景和建议,大多数建议的核心是相通的。

场景1:你认为自己比组员懂得多。

  • 怎么办? 放平心态。COMP1531有固定的节奏,向队友施压让他们更快通常没有帮助。你应该期望他们做好工作,但不要因为自己懂得多就强行要求别人按你的方式做事。只要最终能达到目标,过程并不那么重要。

场景2:组员做得不够。

  • 怎么办? 沟通。通过Microsoft Teams等渠道联系他们。以友好而非指责的方式开始:“嘿,我注意到你最近参与不多,一切都好吗?我们能做些什么来解决?” 处理冲突时,请带着两种心态:一是理解和共情,二是明确需要采取的行动。
  • 关键建议:先问五个问题。 在假设对方是恶意或懒惰之前,先尝试理解。例如,对方可能正在经历家庭或健康问题。从关心对方的福祉开始对话。一旦深入了解了情况,如果确实存在绩效问题,再设定清晰的期望和下一步行动。不要以“我会尽力”这样模糊的承诺结束对话,要明确“你具体在什么时间前完成什么”。

场景3:组员消失了。

  • 怎么办? 先联系他们。如果超过48-72小时没有任何回复,通知你的导师,然后就当ta不会回来了,继续推进项目。大多数情况下,消失的成员要么退课了,要么遇到了严重问题。如果ta之后回来,因为缺席而导致工作量减少和分数降低,那是ta自己的责任。不要被这种情况绑架而停滞不前。

场景4:组员合并了有问题的代码或未经批准就合并。

  • 怎么办? 联系他们,沟通问题,尝试解决,并明确解决的定义。如果你有任何疑虑,告诉你的导师。

重要提示:

  • 避免被动攻击。 不要问“你到底在不在乎这个组?”这类问题。这无助于解决问题。
  • 如果组员粗鲁或行为不当。 礼貌地要求他们停止。如果无法解决,联系你的导师。
  • 在极端情况下(如歧视、骚扰等),不要尝试与对方交涉,直接联系导师或课程负责人。
  • 善用导师资源。 在大多数冲突中,尝试与当事人沟通并设定清晰期望后,如果问题仍未解决,请及时通过邮件(通常比MS Teams更正式)联系你的导师。如果导师无法处理,他们会寻求更高层级的帮助。

本节课中,我们一起学习了团队协作的核心概念。我们从敏捷开发的哲学(个体互动、可工作软件、客户合作、响应变化)出发,了解了冲刺作为时间盒迭代的概念。接着,我们探讨了保持同步的实践(站会、周会)、跟踪任务的工具(GitLab任务板),以及高效的协作方式(结对编程)。最后,我们通过几个常见场景,学习了如何处理团队冲突,核心在于沟通、共情、明确行动以及善用导师资源。

希望这些知识能帮助你在当前及未来的团队项目中更有效地协作。祝你们迭代0顺利,我们下周再见!

008:数据交换 📡

在本节课中,我们将要学习数据交换。这是计算机科学中一个至关重要的概念,它支撑着未来数月乃至数年里你将进行的许多工作。我们将首先从哲学角度探讨标准接口,然后深入研究三种用于以标准方式表示或传输数据的具体技术。

什么是标准接口?🤔

上一节我们介绍了数据交换的重要性,本节中我们来看看其核心思想:标准接口。

标准接口是一种连接不同系统的通用方法。它是一套被广泛认可的简单规则,有助于系统间相互通信。虽然它可能无法处理所有边缘情况,但其核心目标是促进互操作性。

以下是标准接口的一些常见例子:

  • USB 电缆:可以在不同设备(如手机、电脑、耳机)之间实现物理连接和数据传输。
  • 螺栓和螺钉:尽管由不同公司制造,但尺寸(如M4螺栓)遵循统一标准。
  • 互联网协议:例如HTTP,它是网络上交换网页的标准协议。
  • 铁轨轨距:不同地区可能采用不同标准,但标准化的轨距使得跨国铁路运输成为可能。

所有这些系统都有一个共同点:它们提供了一种连接不同系统的通用方法。

数据交换格式 📄

在软件领域,应用程序系统交互最重要的方式之一就是数据传输。我们需要一种标准接口来让数据在不同系统间流动,这就是数据交换格式。

数据交换格式是一种足够简单的“语言”,能让所有人都能理解。它就像人类社会中的挥手告别或微笑,是跨文化的通用表达方式。

本节课我们将讨论三种格式。其中一些更多地用于数据交换,另一些则用于标准数据表示。

JSON:JavaScript 对象表示法

我们将要讨论的主要格式是JSON。JSON是一种简单的标记语言,主要用于描述对象、数字、字符串和布尔值。它功能并不丰富,但非常轻量。

JSON代表JavaScript Object Notation。尽管名称中包含JavaScript,但它实际上与JavaScript关系不大,在其他非JavaScript语言中同样是一种标准。

JSON是一种轻量级的、基于文本的、独立于语言的数据交换格式。

  • 轻量级意味着简单。
  • 基于文本意味着它完全由字符表示,人类可以直接阅读。
  • 独立于语言意味着无论你使用C、JavaScript还是其他语言,它都能工作。

JSON的语法与JavaScript数据结构非常相似,它使用花括号 {} 表示对象,方括号 [] 表示数组,所有非数字项都需要用双引号 "" 包裹。

以下是一个JSON示例,表示一个包含地点列表的对象:

{
  "locations": [
    {
      "suburb": "Kensington",
      "postcode": 2033
    },
    {
      "suburb": "Randwick",
      "postcode": 2031
    }
  ]
}

与JavaScript对象的主要区别在于:

  1. JSON不允许在数组或对象末尾使用尾随逗号。
  2. JSON中对象的键必须是字符串,并且必须用双引号包裹。

JSON的强大之处在于,大多数编程语言都内置或通过库支持读写JSON。这些库的作用通常是将语言特定的数据结构转换为JSON字符串(因为JSON本质上是文本),或者将JSON字符串解析回该语言的数据结构。

以下是使用JavaScript读写JSON文件的核心代码示例:

将数据写入JSON文件:

const fs = require('fs');
const dataStructure = { locations: [ { suburb: "Kensington", postcode: 2033 } ] };

// 将JavaScript对象转换为JSON字符串
const jsonString = JSON.stringify(dataStructure);

// 将JSON字符串写入文件
fs.writeFileSync('export.json', jsonString, { flag: 'w' });

从JSON文件读取数据:

const fs = require('fs');

// 从文件读取文本(JSON字符串)
const jsonStringFromFile = fs.readFileSync('export.json', { encoding: 'utf8', flag: 'r' });

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/96822b5c0dd7257b1aac86c81918d9a3_24.png)

// 将JSON字符串解析为JavaScript对象
const parsedData = JSON.parse(jsonStringFromFile);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/96822b5c0dd7257b1aac86c81918d9a3_26.png)

// 现在可以使用数据了
console.log(parsedData.locations[0].suburb); // 输出: Kensington

这个过程同样适用于其他语言。例如,在Python中,你可以使用内置的 json 库来读取由JavaScript程序生成的 export.json 文件。

import json

with open('export.json', 'r') as file:
    data = json.load(file)
    print(data['locations'][0]['suburb'])  # 输出: Kensington

JSON本身不是数据存储,也不是编程语言。它是一种标准接口定义,一种用于表示数据的文本格式,就像CSV文件一样。它是在不同语言间传输数据的桥梁。

YAML:YAML Ain‘t Markup Language

YAML是一种流行的现代数据交换格式,以其易于编辑和简洁性而闻名。你可以将其视为JSON的替代品,两者各有优劣,并无绝对好坏。

JSON更常用于存储数据和通过网络传输数据。YAML则更常用于配置文件,例如项目的 package.json 如果换成YAML格式可能会更简洁。

YAML的核心特点是缩进有意义。它不使用花括号、方括号和大量引号,而是依靠缩进和短横线 - 来表示数据结构。

以下YAML代码等价于之前展示的JSON示例:

locations:
  - suburb: Kensington
    postcode: 2033
  - suburb: Randwick
    postcode: 2031
  • 最左侧的 locations: 是主对象的键。
  • 缩进表示属于该键的内容。
  • 短横线 - 表示列表中的一个新项。

与JSON相比,YAML的表示更加紧凑,可读性更强,特别适合编写配置文件。但在本课程中,我们主要使用JSON,YAML可能仅在某些场景下被提及。

XML:可扩展标记语言

XML是一种较为陈旧的表达数据的方式。它看起来复杂且冗长,其根源与HTML类似。

XML的每个元素都需要开始和结束标签,导致数据与结构代码的比例很低,大部分内容都是样板标签。在JSON和YAML流行之前,XML曾广泛用于网络数据交互。

以下XML代码同样等价于之前的示例:

<root>
  <locations>
    <location>
      <suburb>Kensington</suburb>
      <postcode>2033</postcode>
    </location>
    <location>
      <suburb>Randwick</suburb>
      <postcode>2031</postcode>
    </location>
  </locations>
</root>

XML在现代语言中已较少使用,本课程也不会涉及。了解它的存在是为了让大家对数据交换格式的演进有一个完整的认识。

总结 📝

本节课中我们一起学习了数据交换的核心概念。我们首先探讨了标准接口的哲学意义,即作为连接不同系统的通用桥梁。然后,我们深入研究了三种具体的数据交换格式:

  1. JSON:我们重点学习的格式。它是一种轻量级、基于文本、语言无关的数据交换格式,广泛用于网络数据传输和存储。核心操作是 JSON.stringify()(序列化)和 JSON.parse()(反序列化)。
  2. YAML:一种更简洁的格式,依赖缩进语法,常用于配置文件,作为JSON的替代选择。
  3. XML:一种较旧的标记语言,结构冗长,在现代开发中已不常用,但为了知识完整性有所了解。

本课程将主要聚焦于JSON的应用。理解这些格式背后的“为什么”比记住具体语法更重要,它们体现了软件工程中标准化和互操作性的核心思想。

009:持续集成 🚀

在本节课中,我们将要学习持续集成。这是一种在现代多用户软件开发中,用于自动化集成和测试代码变更的实践。我们将了解其核心概念、工作原理,并学习如何在 GitLab 中配置一个简单的持续集成流程。


概述

持续集成是一种实践,它自动化了将来自多个贡献者的代码变更集成到单个软件项目中的过程。其核心目标是确保频繁的代码合并不会破坏项目的稳定性。简单来说,它就像一个自动化的检查器,在每次代码提交时运行一系列操作(如构建和测试),以验证代码是否正常工作。

上一节我们介绍了包管理,本节中我们来看看如何自动化地确保代码质量。


持续集成如何工作?⚙️

持续集成的工作流程可以概括为以下几个步骤:

  1. 提交代码:开发者将代码更改推送到 Git 仓库。
  2. 触发管道:如果仓库根目录存在一个名为 .gitlab-ci.yml 的配置文件,GitLab 会检测到新的提交。
  3. 分配运行器:GitLab 将该次提交的所有代码打包,并发送给一个称为 运行器 的专用计算机。
  4. 执行作业:运行器读取 .gitlab-ci.yml 文件中的指令,按顺序执行其中定义的作业(例如运行测试)。
  5. 反馈结果:执行完成后,GitLab 会显示结果(通过 ✅ 或 ❌),告知本次提交是否通过了所有检查。

核心公式
提交代码 -> GitLab 检测 -> 运行器执行 (.gitlab-ci.yml) -> 反馈结果

运行器可以是由 GitLab、GitHub 等平台提供的云端服务,也可以是组织自己维护的计算机。在本课程中,新南威尔士大学 CSE 学院为我们提供了专用的运行器。


配置你的第一个 CI 管道 🛠️

让我们通过一个简单的例子,看看如何在 GitLab 中设置持续集成。

首先,我们需要在 GitLab 仓库的根目录下创建一个名为 .gitlab-ci.yml 的配置文件。这个文件使用 YAML 格式编写,定义了 CI/CD 管道的结构。

以下是一个最基础的示例:

sanity-check:
  script:
    - echo “Hello, CI!”

这个配置文件定义了一个名为 sanity-check 的作业,其脚本是执行一条简单的 echo 命令。

当你将此文件提交并推送到仓库后,GitLab 会自动识别它。在仓库的提交历史旁,你会看到一个状态图标(⏳、🔄、✅ 或 ❌),表示管道正在等待、运行、成功或失败。

你可以点击这个图标来查看管道和作业的详细执行日志。


集成实际测试 🧪

上面的例子只是打印信息,在实际项目中,我们需要运行真正的测试。假设我们有一个使用 Jest 的 JavaScript 项目。

我们需要更新 .gitlab-ci.yml 文件,使其运行测试命令:

run-tests:
  script:
    - npm install
    - npm run test

这个作业做了两件事:

  1. npm install:安装项目依赖(因为运行器是一个全新的环境)。
  2. npm run test:运行项目中定义的测试脚本。

提交这个更改后,每次推送代码,GitLab 运行器都会在一个干净的环境中安装依赖并执行测试。如果所有测试通过,提交会显示绿色对勾;如果有测试失败,则会显示红色叉号,并且可以在日志中查看具体的失败信息。

重要提示:务必确保你的 .gitignore 文件包含了 node_modules/,避免将依赖库推送到仓库。


理解管道、阶段与作业 🔧

为了更好地组织 CI/CD 流程,我们可以定义更复杂的结构。

  • 作业:是 .gitlab-ci.yml 文件中的基本单位,包含一组要执行的 script 命令。
  • 阶段:可以将多个作业分组到不同的阶段中。默认阶段有 buildtestdeploy。同一阶段内的作业默认并行执行(如果资源允许),且通常前一阶段的所有作业成功后,才会进入下一阶段。
  • 管道:一次提交所触发的所有阶段和作业的总和。

以下是包含多个作业和自定义阶段的示例:

stages:
  - checks
  - notifications

lint-code:
  stage: checks
  script:
    - npm run lint

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/12415a1067b04aa810e93705a121a5bc_79.png)

unit-tests:
  stage: checks
  script:
    - npm run test

success-message:
  stage: notifications
  script:
    - echo “所有检查已通过!”

在这个配置中:

  1. lint-codeunit-tests 作业都属于 checks 阶段。
  2. 只有当 checks 阶段的两个作业都成功完成后,才会进入 notifications 阶段执行 success-message 作业。


课程实践与总结 📚

本节课我们一起学习了持续集成的基础知识。

总结一下核心要点

  1. 目的:持续集成通过自动化测试,帮助团队在频繁集成代码时保持主分支的稳定。
  2. 核心组件:依赖于 .gitlab-ci.yml 配置文件、运行器 以及由作业阶段构成的管道
  3. 工作流程:代码推送 -> 触发管道 -> 运行器执行作业 -> 反馈状态。
  4. 最佳实践:始终确保合并到主分支的代码能够通过所有 CI 检查(即保持“绿色”)。应该先编写少量测试,然后实现对应功能,确保通过后再合并,而不是一次性写入大量未实现的测试。

在本课程中,从项目迭代 2(第 5 周)和后续实验开始,你们将正式使用我们配置好的持续集成管道。请利用当前时间熟悉 Jest 测试的编写,并养成“测试先行、小步提交、保持主分支绿色”的良好习惯。

记住,持续集成本身并不能做任何你手动做不到的事情,但它是一个强大的自动化工具,能极大地提升开发效率和代码质量。

010:静态验证 🛡️

在本节课中,我们将要学习静态验证,这是继上周关于动态测试(Jest)讲座之后的逻辑延续。我们将探讨什么是类型安全,并介绍TypeScript语言,通过一系列示例来展示如何利用静态类型检查在代码运行前提升软件的安全性。

概述

上一节我们介绍了通过动态测试(如Jest)来验证软件正确性。本节中,我们来看看静态验证,它旨在在代码运行前(编译时)就发现潜在问题。我们将重点关注类型安全,并学习如何使用TypeScript来实现它。

静态验证与类型安全

静态验证的核心思想是在代码运行前进行检查。类型安全是其主要关注点,它旨在防止变量、常量和函数的实际类型与预期类型不匹配。

例如,在JavaScript中,你可以比较一个数字和一个字符串,代码会运行但可能产生错误结果。而在C语言中,类似的代码会在编译时产生错误,阻止程序运行。这就是类型安全检查的作用。

公式/代码示例:

// JavaScript (非类型安全)
const a = 4;
const b = "hi";
if (a == b) { // 会运行,但逻辑可能错误
    // ...
}

// C (类型安全)
int a = 4;
char* b = "hi";
if (a == b) { // 编译错误:指针与整数比较
    // ...
}

为什么需要TypeScript?

JavaScript本身是动态、弱类型的语言,不提供编译时的类型安全检查。为了弥补这一点,我们引入了TypeScript

TypeScript是构建在JavaScript之上的语言,语法极其相似,但增加了静态类型检查功能。你可以将其视为JavaScript的“保镖”,在运行前对类型进行额外检查。

代码示例:

// TypeScript 函数定义
function sum(a: number, b: number): number {
    return a + b;
}
console.log(sum(1, 2)); // 正确
console.log(sum(1, "2")); // 编译错误:类型不匹配

运行TypeScript代码

由于Node.js无法直接运行.ts文件,我们需要安装相关工具。

以下是运行TypeScript所需的步骤:

  1. 安装依赖库typescriptts-node
    npm install --save-dev typescript ts-node
    
  2. 运行代码:使用ts-node命令。
    ./node_modules/.bin/ts-node yourfile.ts
    
    为了方便,可以在package.json中配置脚本:
    {
      "scripts": {
        "ts-node": "ts-node",
        "tsc": "tsc"
      }
    }
    
    然后通过 npm run ts-node yourfile.ts 运行。

tsc是TypeScript编译器,仅进行类型检查而不运行代码,类似于C语言中的gcc -c

TypeScript核心概念示例

现在,我们通过一些示例来了解TypeScript的核心特性。

1. 基础类型注解

为变量和函数参数添加类型是最常见的用法。

代码示例:

function greet(name: string): string {
    return `Hello, ${name}`;
}
const firstName: string = "Hayden";

TypeScript具有强大的类型推断能力,在许多情况下可以自动推导出类型,无需显式注解。

const firstName = "Hayden"; // TypeScript 推断出类型为 string

2. 联合类型

当一个值可以是多种类型之一时,使用联合类型。

代码示例:

function printIfReady(ready: boolean | number) {
    if (ready === true || ready !== 0) {
        console.log(ready);
    }
}
printIfReady(true);  // 通过
printIfReady(1);     // 通过
printIfReady("now"); // 编译错误:类型不匹配

3. 数组类型

定义数组包含的元素类型。

代码示例:

function createList(item: string | number): (string | number)[] {
    const arr: (string | number)[] = [];
    for (let i = 0; i < 10; i++) {
        arr.push(item);
    }
    return arr;
}
// 或使用 Array<type> 语法
const numArray: Array<number> = [1, 2, 3];

4. 类型别名

使用type关键字创建类型别名,提高代码可读性和复用性。

代码示例:

type ListItem = string | number;
type StringArray = Array<string>;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/71bbe526b35bef4d46131c32b0431a03_77.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/71bbe526b35bef4d46131c32b0431a03_79.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/71bbe526b35bef4d46131c32b0431a03_81.png)

function processItems(items: ListItem[]): StringArray {
    // ...
}

5. 可选参数与属性

使用?标记函数参数或对象属性为可选。

代码示例:

// 可选函数参数
function substring(str: string, start: number, end?: number): string {
    const actualEnd = end ?? str.length;
    let result = "";
    for (let i = start; i < actualEnd; i++) {
        result += str[i];
    }
    return result;
}
console.log(substring("Hayden", 0, 3)); // "Hay"
console.log(substring("Hayden", 2));    // "yden"

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/71bbe526b35bef4d46131c32b0431a03_85.png)

// 可选对象属性
type Person = {
    name: string;
    age?: number; // age 可能不存在
};
const person1: Person = { name: "Hayden" }; // 正确
const person2: Person = { name: "Alex", age: 25 }; // 正确

6. 对象类型

显式定义对象的结构。

代码示例:

type Person = {
    name: string;
    age?: number;
    height?: number;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/71bbe526b35bef4d46131c32b0431a03_95.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/71bbe526b35bef4d46131c32b0431a03_97.png)

const person: Person = {
    name: "Hayden"
};
person.age = 5; // 正确,因为 age 是可选属性

7. 字面量类型

将类型限制为特定的值。

代码示例:

type Visibility = "public" | "private";
function setVisibility(vis: Visibility) {
    // ...
}
setVisibility("public");  // 正确
setVisibility("hidden");  // 编译错误

// 数字字面量类型 (较少用,仅作演示)
type SmallNumber = 1 | 2 | 3 | 4 | 5;
function addSmall(a: SmallNumber, b: SmallNumber) {
    return a + b;
}
addSmall(3, 4); // 正确
addSmall(3, 10); // 编译错误

8. any 类型

any类型是“逃生舱口”,它禁用类型检查,应谨慎使用。

代码示例:

let uncertainValue: any = "could be anything";
uncertainValue = 42;        // 正确
uncertainValue = true;      // 正确
// 使用 any 会失去类型安全保护

类型安全语言分类

  • 内置强制静态类型检查:如 C、Haskell、Java。类型必须声明,编译器严格检查。
  • 内置可选类型检查:如 TypeScript、Objective-C。类型系统存在,但可以部分忽略或宽松处理。
  • 外部可选类型检查器:如 Python (MyPy)、Ruby。类型检查通过第三方工具实现。

课程中的TypeScript

在本课程中,我们将以渐进的方式引入TypeScript:

  • 迭代2开始:项目代码库将迁移到TypeScript。
  • 轻量级集成:初始设置会较为宽松,避免类型错误阻碍开发进度。
  • 持续集成:TypeScript检查 (tsc) 将被添加到GitLab CI流水线中,在每次代码推送时自动运行。
  • 奖励机制:为了鼓励使用,在项目后期,确保代码完全通过类型检查可能会与奖励分数关联。

总结

本节课中我们一起学习了静态验证的核心概念——类型安全。我们探讨了JavaScript在类型安全上的不足,并引入了TypeScript作为解决方案。通过一系列示例,我们了解了如何为变量、函数、数组、对象等添加类型注解,以及如何使用联合类型、可选属性、字面量类型等高级特性来构建更健壮、更易维护的代码。记住,静态类型检查的目标是将错误发现阶段从运行时提前到编译时,从而提升软件的安全性和开发效率。

011:代码检查

在本节课中,我们将要学习代码检查(Linting)的概念。代码检查是一种自动化的工具,用于确保我们的代码符合特定的风格指南,从而提高代码的可读性和一致性。

概述

上一节我们讨论了代码风格的重要性。本节中,我们来看看如何通过自动化工具来强制执行这些风格规则。

代码是为人类阅读而编写的,只是顺便让机器执行。这是一个非常重要的理念。计算机不关心代码风格,例如缩进或空格,但人类关心。良好的代码风格使我们更容易直观地理解代码流程,发现错误,并保持一致性。

什么是代码检查?

代码检查是强制执行代码风格的过程。在早期的编程课程中,你可能需要手动调整代码以符合风格指南。然而,在软件工程项目中,我们使用称为“Linter”的软件来自动进行静态分析并调整代码。

你可以将Linter大致视为一种静态验证形式。它们在代码运行之前检查代码,主要寻找两类问题:

  1. 风格问题:例如缩进、空格、变量命名等。
  2. 语义问题:例如未使用的变量(这虽然不一定是错误,但属于不良风格)。

需要注意的是,Linter并不能解决所有问题,例如糟糕的函数命名或复杂的逻辑结构,但它能帮助缓解许多基础性的风格问题。

使用 ESLint

ESLint 是一个流行的用于静态分析 JavaScript 代码的外部工具。“外部”意味着它不是 Node.js 内置的,你需要像安装 TypeScript 一样通过 npm install 来安装它。

以下是设置和使用 ESLint 的基本步骤:

  1. 安装 ESLint
    npm install eslint
    

  1. 初始化配置(课程中会为你完成此步骤):
    运行 npx eslint --init 并回答一系列问题(例如,项目用途、是否使用 TypeScript、选择哪种风格指南)来生成配置文件 .eslintrc.js

  1. 运行检查
    你可以使用以下命令来检查特定文件的风格问题:

    npx eslint your-file.js
    

    如果终端没有输出,则表示代码符合规则。

  2. 自动修复
    ESLint 可以自动修复许多可识别的风格问题。使用 --fix 标志:

    npx eslint your-file.js --fix
    

  1. 忽略规则
    有时你可能需要暂时禁用某条规则。可以在代码中添加特定注释:
    • 禁用下一行:// eslint-disable-next-line rule-name
    • 禁用整个文件:在文件顶部添加 /* eslint-disable */

注意:在本课程中,所有必要的 ESLint 和 TypeScript 配置都会在相应的实验和项目阶段提供给你,你无需手动进行复杂设置。

集成到工作流

为了便捷使用,我们通常会在 package.json 文件中添加脚本命令。

以下是 package.json 中可能添加的脚本示例:

{
  "scripts": {
    "lint": "eslint .",
    "lint:fix": "eslint . --fix"
  }
}

添加后,你可以通过运行 npm run lint 来检查代码,或运行 npm run lint:fix 来自动修复问题。

将代码检查集成到开发管道中是现代软件开发的标准实践。从第4周实验开始,它将成为你项目工作流的一部分,与测试、类型检查等工具协同工作,共同确保代码质量。

总结

本节课中我们一起学习了代码检查的核心概念。我们了解到代码风格对于人类阅读和维护至关重要,而 ESLint 这样的工具可以自动化地帮助我们执行风格规则。我们探讨了 ESLint 的基本用法,包括检查、自动修复以及如何将其集成到项目工作流中。请记住,保持代码风格的一致性是编写可维护软件的关键步骤之一。

012:高级函数 🚀

在本节课中,我们将要学习JavaScript中的高级函数概念。我们将探讨函数的不同定义方式、一等函数、高阶函数以及数组的mapfilterreduce方法。这些概念是理解现代JavaScript编程的关键,能帮助我们编写更简洁、更易维护的代码。

函数定义方法 📝

在JavaScript中,有三种主要的方式来定义函数。上一节我们介绍了课程背景,本节中我们来看看这些具体的语法。

方法一:传统函数声明
这是最经典的方式,类似于C语言风格,但不需要指定返回类型。

function sum(a, b) {
    return a + b;
}

方法二:函数表达式
这种方式将函数赋值给一个变量。

const sum = function(a, b) {
    return a + b;
};

方法三:箭头函数(现代语法)
这是方法二的简写形式,使用=>符号,语法更紧凑。

const sum = (a, b) => {
    return a + b;
};

箭头函数还有一个便利特性:如果函数体只有单行返回语句,可以省略大括号和return关键字。

const sum = (a, b) => a + b;

一等函数 🥇

一等函数是JavaScript的核心特性之一,它意味着函数可以像其他变量(如字符串、数字)一样被对待。具体来说,函数可以被赋值给变量、作为参数传递给其他函数,或者作为其他函数的返回值。

以下是一个简单的例子,展示了如何将函数作为参数传递:

// 定义两个格式化函数
const brackets = (str) => `(${str})`;
const fullStop = (str) => `${str}.`;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/2a025dce3cfb679d25c2c4c37cada74f_29.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/2a025dce3cfb679d25c2c4c37cada74f_31.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/2a025dce3cfb679d25c2c4c37cada74f_33.png)

// 定义一个接受函数作为参数的函数
const sayHi = (name, format) => {
    console.log(`Hello ${format(name)}`);
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/2a025dce3cfb679d25c2c4c37cada74f_35.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/2a025dce3cfb679d25c2c4c37cada74f_37.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/2a025dce3cfb679d25c2c4c37cada74f_38.png)

// 调用sayHi,并传入不同的格式化函数
sayHi('Hayden', brackets); // 输出: Hello (Hayden)
sayHi('Hayden', fullStop); // 输出: Hello Hayden.

在TypeScript中,我们需要为作为参数的函数定义类型:

type Formatter = (str: string) => string;
const sayHi = (name: string, format: Formatter) => {
    console.log(`Hello ${format(name)}`);
};

匿名函数与数组方法 🛠️

匿名函数是指没有名称的函数,通常用于只需要使用一次的场合。它们在与mapfilterreduce等数组方法结合时特别有用。

以下是mapfilterreduce的核心概念:

  • map: 遍历数组,对每个元素应用一个函数,并返回一个由结果组成的新数组。新数组长度与原数组相同。
    const tutors = ['Simon', 'Theresa', 'Kai', 'Michelle'];
    const shoutedTutors = tutors.map(name => name.toUpperCase() + '!');
    // 结果: ['SIMON!', 'THERESA!', 'KAI!', 'MICHELLE!']
    
  • filter: 遍历数组,用一个函数测试每个元素,返回一个由所有通过测试的元素组成的新数组。新数组长度可能小于原数组。
    const marks = [39, 43, 48, 24, 33];
    const passingMarks = marks.filter(mark => mark >= 50);
    // 结果: [55, 65] (假设有55和65)
    
  • reduce: 遍历数组,通过一个“归约”函数将数组元素累积计算为单个值(如求和、求积)。
    const marks = [55, 43, 34, 23];
    const total = marks.reduce((prev, current) => prev + current, 0);
    // 结果: 155
    

一个结合使用的例子:

const marks = [39, 43, 48, 24, 33];
// 1. 标准化分数(假设满分60)
const normalized = marks.map(mark => (mark / 60) * 100);
// 2. 筛选出及格分数
const passing = normalized.filter(mark => mark >= 50);
// 3. 计算及格分数的总和
const totalPassing = passing.reduce((sum, mark) => sum + mark, 0);
// 4. 计算平均及格分
const averagePassing = totalPassing / passing.length;

高阶函数 🏭

高阶函数是指返回另一个函数的函数。你可以把它们想象成“函数工厂”,用于生成特定行为的函数。

假设我们需要多个函数来祝贺不同成绩等级的学生:

// 传统方式:定义多个相似函数
const congratulateMarkPass = (name) => `Congratulations ${name} on your pass.`;
const congratulateMarkCredit = (name) => `Congratulations ${name} on your credit.`;
// ... 更多等级

使用高阶函数可以更优雅地解决:

// 高阶函数:生成祝贺函数的工厂
const generateCongratulateMark = (mark: string) => {
    // 返回一个新的函数
    return (name: string) => `Congratulations ${name} on your ${mark}.`;
};

// 使用工厂创建特定函数
const congratulateMarkPass = generateCongratulateMark('pass');
const congratulateMarkCredit = generateCongratulateMark('credit');

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/2a025dce3cfb679d25c2c4c37cada74f_109.png)

// 使用它们
console.log(congratulateMarkPass('Hayden')); // Congratulations Hayden on your pass.
console.log(congratulateMarkCredit('Juliana')); // Congratulations Juliana on your credit.

这种方式减少了重复代码,使逻辑更集中,便于维护和扩展。

总结 📚

本节课中我们一起学习了JavaScript中的高级函数概念。
我们首先了解了三种定义函数的方法,特别是现代箭头函数的简洁语法。
接着,我们探讨了一等函数的概念,即函数可以像变量一样被传递和使用,这是mapfilterreduce等方法的基础。
然后,我们深入学习了map(映射)、filter(过滤)、reduce(归约)这三个强大的数组方法,它们能让我们以声明式、简洁的方式处理数据集合。
最后,我们介绍了高阶函数,即返回函数的函数,它像工厂一样能生成特定行为的函数,有助于减少代码重复并提高抽象层次。

掌握这些概念将极大地提升你编写简洁、高效且易维护的JavaScript代码的能力。

013:HTTP服务器 part1 🖥️

在本节课中,我们将要学习HTTP服务器的基础知识。这是课程中最重要的讲座之一,标志着课程前半段较难内容的结束。我们将从宏观的网络概念开始,逐步深入到如何使用JavaScript构建一个简单的HTTP服务器。

概述

本节课是HTTP服务讲座的第一部分。我们不会一次性涵盖所有内容,而是先建立一个基础,以便在后续讲座中更深入地探讨。我们将讨论网络、互联网和万维网的区别,介绍HTTP协议,并最终使用Express库动手创建一个简单的Web服务器。

网络、互联网与万维网

在深入HTTP服务器之前,理解几个核心概念的区别非常重要:网络、互联网和万维网。

  • 网络 是一组能够相互通信的互联计算机。例如,您家中连接到同一个路由器的所有设备就构成了一个网络,即使没有连接外部互联网,它们之间也能通信。
  • 互联网 是一个全球性的网络基础设施,它将世界各地的计算机网络连接在一起。
  • 万维网 是互联网的一个子集,特指通过Web浏览器(如Chrome、Safari)访问和使用互联网的方式。我们常看到的“WWW”指的就是万维网。

核心关系公式万维网 ⊂ 互联网 ⊃ 网络

网络协议与HTTP

计算机之间需要通过共同的“语言”或规则进行通信,这些规则称为网络协议

以下是互联网上一些常见的协议:

  • IMAP/SMTP: 用于电子邮件收发。
  • FTP: 用于文件传输。
  • SSH: 用于安全地远程访问另一台计算机。
  • HTTP: 超文本传输协议。

HTTP 是本节课的重点。它是专门为Web浏览器设计的一种协议,规定了如何在网络上发送和接收文档(如网页、图片)。当您在浏览器地址栏输入一个以“http://”开头的URL时,就是在使用HTTP协议。

使用Express创建HTTP服务器

我们将使用一个非常流行的Node.js库——Express来构建我们自己的HTTP服务器。它每周被下载超过2500万次,以其简洁易用而闻名。

以下是一个最基本的Express服务器代码示例:

import express from 'express';

const app = express();
const PORT = 3000;

app.use(express.json());

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/f4fa3a08b53724cfa2310f0aa5a0d0bb_25.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/f4fa3a08b53724cfa2310f0aa5a0d0bb_27.png)

app.get('/', (req, res) => {
    res.send('Hello World');
});

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/f4fa3a08b53724cfa2310f0aa5a0d0bb_29.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/f4fa3a08b53724cfa2310f0aa5a0d0bb_31.png)

app.listen(PORT, () => {
    console.log(`Listening on port ${PORT}`);
});

让我们来分解这段代码:

  1. import express from 'express';: 导入Express库。
  2. const app = express();: 创建一个Express应用实例,这就是我们的Web服务器。
  3. const PORT = 3000;: 指定服务器监听的端口号(稍后详细解释端口)。
  4. app.use(express.json());: 这是一行配置代码,让服务器能够解析JSON格式的数据,目前只需知道需要它。
  5. app.get('/', ...): 这定义了一个路由。它告诉服务器:当客户端通过HTTP GET 方法访问根路径/时,用字符串Hello World作为响应。
  6. app.listen(PORT, ...): 这行代码启动服务器,让它开始监听指定端口上的请求。没有这行代码,服务器就不会运行。

运行服务器与端口

运行上述代码后,服务器会启动并持续运行(类似于一个无限循环),等待接收请求。

要访问这个服务器,您需要在浏览器中输入地址。由于服务器运行在您的本地机器上,地址是 localhost 加上端口号,例如:http://localhost:3000

端口 是计算机上的一个虚拟“通道”或“门牌号”,不同的网络应用程序可以通过不同的端口同时运行。端口号范围很大(0-65535)。

关于端口选择,有以下惯例:

  • 0-1023: 知名端口,通常被系统或核心服务占用(如HTTP默认用80,HTTPS用443),开发时避免使用。
  • 1024-49151: 注册端口,某些应用程序可能会使用。
  • 49152-65535: 动态或私有端口,通常用于临时或开发用途。

在开发时,建议选择3000以上的端口号(如3000, 5000, 8080)。如果在VLab等共享环境中遇到“地址已在使用”的错误,很可能是因为其他同学恰好使用了相同的端口号,只需更换一个端口即可解决。

您可以为不同的路径定义不同的响应:

app.get('/hey', (req, res) => {
    res.send('hey');
});

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/f4fa3a08b53724cfa2310f0aa5a0d0bb_44.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/f4fa3a08b53724cfa2310f0aa5a0d0bb_45.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/f4fa3a08b53724cfa2310f0aa5a0d0bb_47.png)

app.get('/bye/bye', (req, res) => {
    res.send('bye bye');
});

重启服务器后,访问http://localhost:3000/heyhttp://localhost:3000/bye/bye 就会得到相应的响应。

总结

本节课我们一起学习了HTTP服务器的基础知识。我们首先区分了网络、互联网和万维网的概念,然后了解到HTTP是万维网上用于传输文档的核心协议。接着,我们使用Express库动手创建了一个简单的HTTP服务器,学会了如何定义路由来处理不同的URL请求,并理解了端口的概念及其在开发环境中的使用注意事项。

这为我们下一节课深入探讨API设计和CRUD操作奠定了坚实的基础。

014:HTTP服务器 part2 🚀

概述

在本节课中,我们将继续学习HTTP服务器,重点探讨Web API的概念、RESTful API的设计原则,以及如何使用Node.js和Express框架构建一个能够处理不同HTTP方法(GET, POST, PUT, DELETE)的服务器。我们还将学习如何使用API客户端和代码库与服务器进行交互,并最终将这些知识应用于编写HTTP测试。


快速回顾

在上一讲中,我们创建了一个基础的Node.js Express服务器。它有一个URL端点,当服务器运行时访问该URL,会从服务器返回一些信息,具体来说是一个基于文本的消息。

上一讲幻灯片的内容主要是为了让大家理解Web服务器的一种非常常见的用途。

Web服务器的两种主要用途

这是一个非常概括的说法,但你会看到Web服务器主要有两种用途。

  1. 提供静态内容。这意味着通过URL获取该URL上的数据。这在互联网上时刻都在发生,例如在维基百科或内容管理系统(CMS)网站上,你通过URL加载一个内容页面,加载的页面是用某种Web语言编写的。

  1. 构建API。这是Web服务器更通用、更核心的用途。我相信你在学习过程中已经多次听到过API这个词。

什么是API?

API代表应用程序编程接口。这个术语非常字面化和概念化。

它指的是特定软件暴露出来的一个接口。当我们说“暴露的接口”时,对于处于你们这个位置的大多数人来说,可以理解为就像登录GitLab时看到的那样,它基本上就是一组函数。

我想指出这一点是因为,在我们进一步抽象讨论之前,我想先指出一个你们最近几天可能一直在交互的接口,那就是你们正在进行的项目。

在当前这个迭代(迭代一)中,我们有一个接口表。这个接口表本质上就是一份契约,规定了特定服务之间的交互方式。

接口实际上就是软件与使用者之间的一份契约。它表明:“我是一段软件(例如1531大项目),我有一些函数,这些函数能完成特定任务。” 契约的一边是使用这些函数的人,另一边是像你们现在做迭代一这样去实现这些函数的人。

API简单来说就是一段软件暴露的接口。因此,你可以认为这个接口表就是一个API。它可能是一个文档不全的API,但它仍然是一个API,因为它是一个软件的抽象,隐藏了实现细节,允许他人知道如何使用它。

在迭代一的上下文中,实际上是你们的测试在使用你们的API。你们有这个接口表(即API),然后去实现后端逻辑,接着有人(也就是你们的测试)会使用你们的代码并尝试用它来运行程序。未来,这可能是一个实际的网页或网站。

所以,这就是API。

Web API

如今,我们看到最常见的API用法是Web API。这仅仅是因为API通常存在于以下场景:A创建了一些软件,他们创建了一个易于理解的接口,并构建了所有复杂的后端。然后,B、C、D、E等人使用这个API。这是最常见的情况。

让世界各地的人们使用软件的最简单方式是通过互联网,而利用互联网的最简单方式就是通过我们上一讲讨论的Web。

因此,接口就像一个黑盒,它指明了对于特定的端点,其输入、输出和一般行为是什么。

回到这个接口,以authLoginV1函数为例。它告诉你它的功能、参数、返回类型和错误情况,但它并不展示具体的实现,因为你把它当作一个黑盒来处理。

这就是API。

Web API的世界

在Web API的世界里,事情变得更有趣一些。Web API就是基于HTTP协议(即我们之前讨论的Web协议)构建和发送的API。它完全基于URL,因为HTTP协议就是一堆来回发送的URL。

理解现代应用程序中Web工作原理的一个重要点是:像我现在使用的Microsoft Edge这样的Web浏览器,会向服务器发出请求,说:“嘿,我能加载这个网页吗?”然后你得到返回的网页。这在Web CMS上时刻都在发生,是一个非常简单的例子。我加载页面,页面返回,然后显示在屏幕上。

然而,在初始页面加载之后,如今大多数应用程序的一个非常常见的做法是,它们会通过某种API持续与服务器通信。例如,如果我打开YouTube(这里我没有登录),并使用浏览器中的调试工具,你会看到为了加载这个网页,所有你在这里看到的东西——不同的文件、不同的图片、Mr. Beast的图片、Mr. Bean的图片——这些都是服务器带给浏览器的不同文件。但这不一定与API直接相关。

API更有趣的地方在于,如果我点击页面上的某个特定按钮,例如“音乐”,浏览器将不得不与服务器通信以获取更多信息。所以,实际上,YouTube正在与YouTube API对话,它们不断地获取信息。

这并不意味着YouTube必须使用自己的API。一个公司使用另一个公司API的常见例子是Airbnb。当你在Airbnb网站上向下滚动并看到地图时,Airbnb实际上正在与Google API对话。Google拥有像你们大项目那样的Google Maps软件,他们提供了Google Maps API。

想象一下,你是一个软件开发人员,你想创建一个类似Uber的应用,并希望告诉司机去哪里。你可以使用Google Maps Directions API。

我再举一个更简单的例子。有一个很棒的产品叫Canny,它是一个美国的小公司,主要做客户反馈管理工具。如果你开发了一个小产品或项目,你可以使用他们的产品让用户投票他们想要的功能。有趣的是,Canny有一个API。你会发现很多流行的服务都有API。

如果我进入Canny的API文档,你会看到它就像一个论坛,有发帖、评论等功能。如果你想在帖子上创建评论,它会告诉你:“好的,这里有一个函数(本质上是URL)。如果你把HTTP URL看作是函数,会让事情更容易理解。” 然后它会说明参数、参数类型以及返回的内容。

这是一个非常普遍、常见的概念。不仅仅是我们在1531项目中抛给你们的一个想法,在Web服务器的世界里,URL、参数、返回类型等等都是相当标准的。你会发现大多数软件都有这个,比如Google Sheets、Google Docs都有自己的API。

所有这些API文档的核心思想都非常相似。它有一个网站URL,有一些可以接收的字段,告诉你可能返回什么。这基本上就是大量的文档。

现代网站的工作方式

回到幻灯片,如今大多数网站的工作方式是:它们加载页面,然后该页面通过某种API不断与服务器通信以获取和收集数据。这就是许多现代网站为你做事的方式。例如,在YouTube上,如果你喜欢一个视频,它只是突然被“点赞”了,但实际上,后台的YouTube网页正在通过API与YouTube服务器通信。这可能是一个公司内部的API。

RESTful API

通过这种基于Web的API进行通信的一种非常常见的方式是构建某种RESTful API。RESTful本质上是一个使用四种HTTP请求的API,这四种请求是:GETPOSTPUTDELETE

这四种方法描述了你在Web服务器上可以执行的所有主要操作。

让我们回到上一讲的代码,因为如果你看到一些代码,这会更容易理解。这是我们之前的代码,我们有我们的4.2 express basic示例。你注意到我们已经有了这四种类型中的一种,即GET请求。

这里的app是我们的Web服务器。当我们说app.get(...)时,我们实际上是在告诉Web服务器,我们想要发出一种特定类型的HTTP请求。记住,HTTP是用于在Web上进行通信的协议。

但有四种主要的方法被使用:GET、POST、PUT、DELETE。这四种方法合起来基本上意味着:GET表示读取(很合理,就像“获取”一样),POST表示创建,PUT表示更新,DELETE表示删除。

我们有时将这四种操作称为CRUD操作(创建、读取、更新、删除)。所以有时你会说“我有一个RESTful API,只是做一些基本的CRUD操作”,这实际上意味着你正在像这样使用POST、GET、PUT和DELETE。

现在,当我们看这个特定的服务器时,我们会更多地讨论这些方法的含义。但首先,为了让你入门并深入一点,很常见的情况是,你会有一个服务器,上面有一堆GET请求,可能还有一些POST请求、DELETE请求等等。你有了这些GET、POST、PUT和DELETE方法。

你可能会想,这四种东西是什么,它们有什么不同?这些操作本身在本质上并没有严格的含义,它们更像是一种约定。就像有时你用大写字母创建常量变量,有时用小写字母。一个你打算不被修改的变量实际上可能被修改,但我们使用约定来表述和沟通事物。

所以,并不是说这里的GET请求在“不能修改任何东西”的意义上是完全只读的,因为任何事情都可能发生。作为程序员,你完全可以让一个GET请求去删除整个网站。这更多的还是一种约定。

查看代码

让我们看一些代码,我知道这对一些人来说可能有点难以理解。

我们有一个非常基础的服务器,它做了一些非常有趣的事情,实际上是上一个服务器的扩展。让我指出一些关键的区别。

首先,我有两个HTTP路由(我们称之为路由),即/apple/orange。这意味着如果我访问http://localhost:3001/apple(因为我们在3001端口运行),在浏览器中输入这个URL并且服务器正在运行,它就会找到这个特定的函数。然后,函数内部的代码将被调用和执行。

你会注意到,我的GET请求/apple,如果我们启动它,然后访问这个URL,你会看到我得到了一个有趣的响应,它说“Hi undefined, thanks for sending apple”。

我回到代码中查看,发现const name = request.query.name。然后你会看到我正在发送一些东西回去,但如果你记得第一个例子,我发回的是一个字符串(一段文本),而这里我发回的是一些JSON。我抓取这个对象(一个包含键值对的对象),键是message,值是字符串“Hi ${name}, thanks for sending apple”。然后我使用JSON.stringify把这个对象转换成字符串,因为互联网不理解JavaScript对象,但它理解ASCII。通过JSON进行字符串化,我可以将我正在做的事情转换成ASCII,这非常有用。

所以这很合理,和上一个例子非常相似。我不是返回原始文本,而是返回一些其他文本的JSON字符串版本。

然后你会看到,名字我可以重命名为Hayden之类的,重启服务器后,它会按预期运行,输出与Hayden相关的内容,而不是undefined。但我们不想让它固定是Hayden。

那么,这个request.query.name是什么呢?Web的工作方式是,某些类型的请求(如GET请求)如果想要路由接收信息,因为也许当我调用/apple时,我希望它根据输入返回一个友好的消息,但我如何输入呢?这不是一个函数,我不能直接在这里添加一个参数。我尝试过,但那行不通。

在基于Web的交互中,我们传递变量的方式是通过URL。你可能经常看到这种情况。如果我说localhost:3001/apple?name=Hayden,问号后面的信息基本上就是函数参数。

当我运行这个时,你会看到我得到了相同的结果(但这次我必须重启服务器,因为我之前修改了代码)。当我刷新时,它现在可以工作了。如果我把名字改成Sally,它现在会说Sally。

真正有趣的是,你已经开始看到这是如何形成的:我不再是拥有带参数和返回类型的函数,而是拥有一个URL,它基本上就是一个函数名。那个URL也通过URL接收参数,然后它向网页返回某种结果,在这个例子中只是一个字符串,在大多数情况下是某种字符串。

这实际上是API的基础。你会发现大多数Web API的结构在很多方面都与这非常相似,因为它实际上就是一组URL的集合,接收参数,然后返回某种字符串响应。

如今,这些字符串响应通常基于JSON,因为正如你在3.1讲(关于数据交换格式和标准接口)中回想起来的那样,这是一种简单的方式,无论服务器是用C、JavaScript、Python还是Java构建的,你总是可以发送回信息,这是一种每个人都理解的语言。所以,如果前端有一个不用Java运行的程序,你仍然能够解析这些信息。

这就是关于API的更多阐述。你可以开始想象你可以有一个非常庞大的API。例如,想象我创建另一个叫做app.get('/time')的路由,然后在里面,我只需要发回一个字符串化的JSON对象,对象里包含time键,其值是new Date().toLocaleString()(一种友好的字符串格式)。

现在我重启服务器,我创建了另一个路由,或者说是Web接口上的另一个函数。如果我不去/apple,而是去/time,它实际上会给我时间。当我刷新时,我得到新的时间。

这非常方便,因为现在你可以想象我们正在构建一个API,这个API现在有一个时间路由或时间函数。没有参数,它是一个无参数、无参数的函数,但它返回一些东西,那个东西也是一个字符串。

我们可以不断扩展这个概念。

RESTful API的有趣之处

现在,RESTful API的有趣之处在于,并非所有东西都只是GET方法。我们还有这些其他方法:POST、PUT和DELETE方法。

同样,GET意味着读取。所以我们这里的两个路由get /appleget /time,都是我们在读取一些信息,我们没有创建、更新或删除信息,我们只是在读取它。

浏览器与HTTP方法

然而,大多数Web浏览器,就用户(像你和我)可以通过输入URL来使用的功能而言,总是通过GET发送请求。这就是它们构建的方式。所以,当你在地址栏中输入一个网站或URL并按下回车时,它会为你向该特定URL发出一个GET请求到服务器。

我可以通过再次打开网络标签页并刷新来轻松看到这一点。如果我看第一个请求,你可以看到它是一个GET请求。你不需要看我下面显示的任何信息,那些完全不相关。但正如你所见,它始终是GET请求。

为了与Web服务器进行比浏览器(功能相当有限)所能做的更有趣的对话,我们实际上需要以某种方式与它交谈。

与Web服务器通信的三种方式

在本课程中,我们讨论了三种与Web服务器通信的主要方式:

  1. 通过API客户端(本质上是一个为与API对话而设计的工具)。
  2. 通过Web浏览器(我们到目前为止已经演示过)。
  3. 通过一个NPM库(本质上是一个帮助你发出请求的编码库),这对迭代二非常有用。

API客户端

在API客户端方面,API客户端只是一个用于与API对话的程序。有很多这样的程序。我在工作中使用一个叫Insomnia的,它很好用。Postman是一个常见的。我们在1531中通常推荐一个轻量级的叫ARC(高级REST客户端)。

你可以在大多数基于Chrome或Firefox的浏览器上安装这些。例如,在Chrome中搜索“ARC add-on”就可以找到。它像一个浏览器应用程序一样运行,所以你不需要安装到系统,它通过Chrome引擎运行。

还有其他客户端,比如Postman API,但有时它们会停止通过Chrome插件库提供服务,转而作为专用应用程序提供,就像你下载到电脑上的那样。

使用这些API客户端,你可以输入URL和参数。例如,我可以输入localhost:3001/apple?name=Hayden,点击发送,就会得到结果“Hi Hayden, thanks for sending apple”。如果改成Sally,它就说Hi Sally。

这就像一个可以轻松完成这些操作的小工具。你可能会想,这和Web浏览器有什么不同,或者这有什么令人兴奋的?答案是,我实际上可以改变我发送请求的方法。在ARC里,我可以选择通过POST、PUT或DELETE发送。

如果你看我的代码,你会看到在我的服务器中,我还有一个POST方法叫做/orange/orange做的事情类似,但也略有不同。它仍然返回“Hi so-and-so, thanks for sending orange”。但我在这里消费信息的方式有点不同。

这就是事情变得奇怪的地方。你会注意到,对于GET请求,我通过URL消费信息。request是传入的消息,query是URL参数,name是它的一个属性。为了更清楚,我也可以发送多个东西,比如nameage,然后修改我的字符串。

你可以通过URL发送很多信息,只需在后面添加更多信息。URL的工作方式是:数据通过问号与路径分离。URL由协议(HTTP)、域名(或通常所说的网站)、端口(在生产服务器上通常是相同的,所以使用互联网时通常不需要)组成,然后我们称这里为路径(类似于函数名),接着是数据。数据只是key=value的形式,对于每一段数据,我们使用&符号来分隔。

所以我们可以这样添加更多信息。但是,当涉及到像这里看到的POST请求时,我们不是通过query捕获信息。这是因为对于POST请求,我们通过所谓的请求体发送信息,这可能是一个新概念。

所以,如果我要向/orange发出POST请求,我不需要任何查询参数。但如果我想发送信息,比如名字,我实际上必须转到这里的“Body”部分,并且发送一些JSON。例如,JSON会说{"name": "Hayden"}

所以,我不是把它放在查询字符串里,而是现在把它放在请求体里。你可以这样理解请求体:把HTTP想象成一封信。我写一封信:“Hi Gab,很高兴在1531讲座中见到你,真是段美好时光。” 我把信装进信封,写上地址和回信地址。这个类比并不完美。

使用查询字符串,就像在GET请求中把数据放在URL里,是在地址上添加更多信息。它是公开可见的,就在那里,非常简单,你不必打开信封塞进更多数据。

而把它放在请求体里,就相当于把它放在信封里的信里面。在互联网上发送信息时,通常邮局工作人员可以看到你把邮件寄到哪里,但他们不知道里面是什么。我在这里简化了一些事情,但这就是我们通过请求体发送信息时所做的事情。

当我点击发送时,我可能会得到一个错误,因为我的代码可能有错误。你会注意到,当我调用这个Sally的URL时,我得到了一个绿色的小标记,写着“200 OK”。在Web开发中,你会看到一些非常重要的错误代码:

  • 200:成功。
  • 400:基本上意味着传入了无效参数。
  • 403:表示你未被授权访问此资源。
  • 404:表示此路由不存在。
  • 500:表示服务器端出了严重问题。

所以,当我查看结果时,这个(GET请求)是200 OK。但当我调用/orange路由时,我得到了一个500错误。那个500错误基本上是说服务器有错误。实际上,如果我回到错误信息,你可以在终端上看到服务器有错误。

你会注意到这些错误代码的逻辑划分很清晰:200系列表示一切正常;500系列表示服务器(也就是你在这门课程中写的代码)犯了错误;400系列表示向服务器提供信息的人(客户端)做错了什么。

例如,如果我尝试访问一个拼写错误的HTTP路由,比如/orangee,我会得到404错误,这意味着我,这个试图与你的服务器对话的人,犯了错误。如果我做对了,那么就是200成功。同样,我也可以把请求体里的名字从Hayden改成Sally,我们会看到新的请求过来,并说“Hi Sally, thanks for sending orange”。

这就是一个POST请求。

HTTP方法的语义

现在,如果你在注意听,你可能会记得我们之前说过GET请求是关于读取,POST请求是关于创建,PUT请求是关于更新,DELETE请求是关于删除。你可能会想:“Hayden,但你并没有创建任何东西。你在这里发了一个POST请求,但你没有实际创建任何东西,你只是打印出了名字。”

你是对的。因为如果我们再往下滚动,POST请求实际上并不强制你做任何事情。它们只是通信工具。所以我的这个POST请求可能应该是一个GET请求,我应该把它重命名为GET请求,因为我没有创建、更新或删除任何东西,我只是在读取一些东西。

所以,这非常重要。这四个关键词是指示性的,它们不是强制约束,在很多方面也不是技术术语。关于它们有一些细节,但“正确使用”它们更多是基于约定,而不是服务器有严格的技术限制。服务器不会说:“嘿,你说这是只读的,但你现在修改了东西。”

代码示例与总结

这是我们刚刚看过的代码片段。我们谈了一点关于使用query,也谈了一点关于使用body。基本上,HTTP和CRUD的设置方式(这实际上已经有点固化了)是:GET和DELETE请求不接收请求体,你倾向于像函数参数一样通过URL向它们发送信息。所以,当你调用GET或某种DELETE请求时,信息会通过URL传来。

而当你有一个PUT请求或POST请求(我们看到了一个POST请求)时,你通过请求体发送信息,就像你在ARC上看到的那样。

所有这四种请求(GET、DELETE、PUT、POST)都将通过JSON发送数据回来。同样,这不是服务器的要求,只是你所说的常见约定。你会发现外面大多数API(我敢说99%以上)都会返回JSON。

同样,我有点略过的是,通常大多数服务器会期望你也通过JSON发送请求体信息。我们隐含地做到了这一点。如果你看我的请求体,这是有效的JSON。在这个例子中,我们以文本形式发送它,我们只是通过互联网发送一个字符串。一旦我们的服务器捕获到它,它就用JSON.parse解析那个字符串。它获取请求体,并将其解包成JavaScript对象。这就是我们从body得到的东西,然后我们从那个对象中取出name。所以这里的body是一个带有一个键的对象。

我提到过,99%的API都用JSON,总是返回JSON,而对于POST和PUT操作,它们通过请求体接收JSON。因为有人意识到每个人都在这么做,所以对Express做了一些修改。

如果你的服务器几乎只处理JSON,你可以将服务器从处理文本改为处理JSON。同样,当你使用response.send时,你不必说“我想让你发送文本,并且那个文本是JSON化的JavaScript对象”,你可以直接说response.json(...)。本质上,Express会为你做同样的事情。它会说:“哦,谢谢你的JavaScript对象,我会为你把它转换成JSON并发送回去。”

第三,对于PUT和POST请求,如果你在代码顶部有app.use(express.json())这一行,你就不必做JSON.parse。通过将处理方式从文本改为JSON,你本质上告诉了Express你得到的所有输入都是JSON格式。所以当你获取request.body时,Express实际上会为你解析它,并把它变成一个JavaScript对象,这非常好。

所以你会看到,当我停止并再次运行它时,我会得到相同的结果。只是我写了更少的代码,这很好。我再次运行那个,运行良好。GET请求也运行良好。对于GET和DELETE的输入,你不需要对JSON做任何处理,它们只是像这样通过URL传递。这样没问题,还有其他方法可以做到,但这只是我们在这门课程中教你的方式。

这些是与前一个例子相比发生改变的关键代码行。

使用NPM库进行HTTP请求

现在,让我们进入一个更令人兴奋的部分,也是你们可能会经常用到的部分:一个允许你发出HTTP请求的实际NPM库。

我这里有一些代码,它已经在一个文件里准备好了。我使用了一个特定的NPM库叫做sync-request。它本质上是一个HTTP库,我必须打开另一个终端。它是一个允许我们做ARC或其他API客户端所做的事情的库,只不过是在终端或更具体地说在代码中完成。

要使用它,我只需要运行npm install sync-request

现在我可以运行这个文件,这个文件所做的就是从一个叫sync-request的库导入一个request对象,然后发出一个请求。你可以看到这里的请求是一个GET请求,指向URL localhost:3001/apple?name=Hayden。最后,我获取响应,这有点冗长,但我得到了响应。所以这是发送到服务器的请求。然后当我得到响应时,我从响应中获取body。你已经在下面看到了,然后我把它转换成字符串(据我所知,你必须这样做),然后把它作为JSON解析(这不是自动的),然后console.log它。

现在,因为服务器还在运行(记住我们的服务器仍在运行),我实际上可以在另一个终端里运行npm run ts-node source/4.2-request.ts,它会去和服务器对话。

所以,哦,“Hi Hayden of age undefined”。这很有趣,为什么是这样?我不确定。这不太合理。哦,对了,因为我们添加了那个额外的东西。所以,同样地,如果我想添加其他信息,我可以直接在这里添加&age=18。然后我们只需要重启服务器,再次运行我们的测试。

这里发生的一切就是:你的服务器(实现)在这里运行,而你的其他代码只是在和那个服务器对话。这就是真正发生的一切。

所以这个库非常非常简单易用。这就是你用GET请求和DELETE请求的做法,因为你只需要说getdelete,然后通过URL传递信息。

但还有另外两件重要的事情:

  1. 如果我想简化我的代码怎么办?嗯,你实际上可以更进一步。我可以清理一下我的代码,说:“你知道吗,我传入的所有参数(或称为查询字符串)只是{ name: 'Hayden', age: 18 }。” 所以,我不必手动写进去(特别是如果你有动态内容,这会有点复杂),我可以实际删除那部分,转而传入一个查询字符串对象。或者如果我把它重命名为querystring以便更容易理解,请求库接受这个额外的属性qs,它就是查询字符串变量,它应该为我们做完全相同的事情。这使我们的代码更清晰、更动态,而不必手动将信息直接输入到URL中。

  1. 下一个逻辑问题是:我的POST请求怎么办?我们知道,对于POST请求,我们调用/orange,并且我们知道它不是GET请求,而是POST请求。然后我们在POST请求中传递的请求体基本上就是你从其他例子中期望的那样。我们想发送一些文本,通常你可以说,好吧,假设文本只是{ name: 'Hayden' }。这应该大部分能工作,因为这就是我们发送的请求体。

让我们看看这是否可行。我运行这个。它说“Hi undefined, thanks for sending orange”。现在,使用这个库的一个恼人现实是,实际上你需要包含一个非常关键的行(或几行),这在我们给的所有示例代码中都有,但我会在这里包含它:除了你的body,你还需要包含这个叫做headers的东西和其他一些内容。不要太担心这是什么,但这本质上是随你的请求一起发送的信息,它告诉服务器:“嘿,我正在发送JSON”,所以它知道接受JSON。这有点奇怪,但我很确定这应该能工作。所有这些新代码都是我这学期写的。

所以你现在会看到它实际上会接受它,因为它就像:“哦,我现在明白我得到什么了。我得到了一些有请求体的信息。” 所以这工作得很好,但显然我们不想在这里写手动字符串,所以我们可以像以前那样做,你可以直接给它JSON.stringify({ name: 'Hayden' })。现在你可以发送它,然后它就会正常工作。

所以,sync-request库可以很好地处理GET、DELETE、PUT、POST。我刚刚给了你们一些非常直接的例子来演示所有这些。这完全正常,完全没问题。

编写HTTP测试

你们在迭代二中要做很多的事情就是扩展这个,特别是你们将使用这些库来编写测试。因为我到目前为止向你们展示的是如何编写一个有/apple/orange等端点的服务器,以及如何与该服务器对话。但下一步是弄清楚如何编写与该服务器对话的测试。

所以我们休息后会做这个。

将各部分结合起来

现在,我们将尝试实际将这些部分结合起来,让你们更好地理解迭代二(关于HTTP和Web内容)是如何结合在一起的。因为你们知道迭代一是关于编写测试和实现功能,迭代二也是关于编写测试和实现功能,但那些测试是基于HTTP的。

我这里有一个服务器,它正在按预期运行,然后单独地,我有一个我创建的好文件,叫做4.2-request.test.ts。你会看到它实际上看起来和我们的请求文件非常相似,只是我们把所有这些都包装在了Jest里面。

现在,Jest能够描述一些东西,测试一些东西,然后实际运行测试。然后我们做的是,我们期望返回的bodybody是返回对象上的键)的message等于“Hi Hayden, thanks for sending apple”。然后我们对test orange做完全相同的事情。

这很棒,因为我们可以直接运行npm run jest source/4.2-request.test.ts

调试与抽象

(注:讲师在此处遇到了环境配置问题并进行了调试,最终在本地环境解决了问题,演示了调试过程:缩小测试范围、在不同机器上尝试、检查代码差异、添加日志等。)

现在我们已经演示了我们可以运行我们的小程序,让我们实际运行之前尝试运行的Jest测试。Jest这次应该很高兴。好了,现在我们在Jest上有了两个勾,因为Jest在这里运行了那两个测试。

所以你可以看到,在迭代二中,使用HTTP做的很多事情真的很相似。只是,你不是简单地调用一个函数,而是在处理这个HTTP层。这在某些方面意味着你可能需要更新一些代码。但这也没关系,因为这看起来有点庞大和可怕,但想一想,这是一个巨大的提示:你可以通过创建一个函数来简化这一大堆代码。

例如,我写一个函数叫get(这是个糟糕的名字),它接收一个path和一个querystring。然后我把所有相关的代码放进去。我们知道这是通用的。所以现在在我的测试里,我不必写所有这些,我实际上可以说const res = get('apple', { name: 'Hayden', age: 18 })。突然之间,我的代码大大简化了。突然之间,它和你现在做的并没有太大不同,只是语法略有不同。

事实上,为了真正说明这一点,如果我想要一个POST函数怎么办?我们知道POST函数非常相似,只是它接收一个path和一个body。所以我们可以类似地抽象出来。然后,我们不必写所有这些,我们可以直接写post('orange', { name: 'Hayden' })

这真的很棒,因为如果你定义了一次,那么你就可以重用它。然后,如果你想更进一步,真正深入高级函数讲座中的例子,我们在高级函数讲座中学到了高阶组件的概念。让我们在这里实际使用它。

我们可以创建一个更通用的函数,比如叫httpSend,它接收methodpathquerystringbody等。然后我们发现getpost函数有很多共同点,除了方法(get vs post)和请求配置对象里的某些部分。我们可以创建一个高阶函数(一个返回函数的函数)来生成这些特定的请求函数(get, post, put, delete)。

这个例子有点复杂,因为getpost的“额外参数”不太一样(getqspostbodyheaders),所以需要一些条件判断。但本质上,我们创建了一个大的工厂函数,可以生成通用的GET、PUT、POST、DELETE函数,减少了重复代码。

httpSend是一个生成其他函数的函数。当我们用'get'调用httpSend时,它创建一个内部函数。现在每次我调用get,就像调用那个内部函数一样,只不过方法被替换成了'get'。用'post'调用时也一样。

但通常,我不认为这种事情值得做,因为重复的代码量相当少。更清晰的代码可能更有价值。下周我们会多谈谈这个,你必须小心,不要为了让代码减少一点点重复而把事情搞得过于复杂,以至于每个人都困惑,没人知道发生了什么。

总之,这是一个有点跑题但有趣的插曲。但这就是Jest,这就是我们把所有东西结合起来,让你们为迭代二做好准备。

迭代二的关键点

正如我多次提到的,迭代二要求你实现HTTP服务器。然而,迭代二中存在的许多路由只是迭代一路由的包装器。这是一个很好的点,因为正如你将在周一(第7周周一)看到的,你将有一堆需要为这个项目编写或实现的路由。

但会有像auth/registerauth/login这样的东西。这真的非常非常重要。以auth/login为例,它接收一个emailpassword。我们从请求体获取这些。然后我们可能要做的是调用authLoginV1,并传入emailpassword。然后我们得到的结果,authLoginV1返回什么?它返回一个authUserId

所以,你基本上就是把迭代一已经完成的功能包装成一个HTTP服务器。关键是,我们并不是想吓唬你们,让你们觉得“天哪,我得把所有这些东西再做一遍”。实际上,你们已经编写了很多基本功能,对于很多这些基本功能,你只需要在服务器层面包装一下输入输出,这样当有人调用那个URL时,你只需提取参数,传递给你在迭代一中编写的函数,然后返回该函数给出的结果。

这实际上是非常可控的,它把你迭代一的函数当作黑盒来处理。你们的导师非常非常乐意在这方面提供帮助。

有用的开发工具

现在,最后一些非常有用的技巧,我已经穿插在这次讲座中了。

第一个是一个非常有帮助且强大的库,叫做Nodemon(我相信它代表Node Monitor)。它的工作方式是:当我们运行一些代码时,特别是像Express服务器这样永远运行的代码,它会监听计算机上的文件系统。如果它看到你保存了任何它用来运行的文件,它实际上会重启进程。

所以,我安装了Nodemon。现在,在我的package.json里,我已经有了ts-node这一行,但我可以添加这个nodemon行。现在,特别是当我运行服务器时,我可以直接使用nodemon。Nodemon足够聪明,能判断你的代码是JavaScript还是TypeScript,并乐意运行任何一种。

Nodemon令人兴奋的地方在于,你会看到它现在正在监听我的服务器。但看看当我保存文件时终端会发生什么。Nodemon一直在监听文件系统,然后它重启了服务器。所以现在,我不必停止和启动它,如果我再次运行它,文本就更新了。这是一个非常非常有用的开发工具。

第二个有用的工具与TypeScript有关。基本上,在开发过程中,你应该经常运行npm run tsc。TypeScript会对你的所有文件进行相当严格的检查,并可能发现错误。但与其运行这个然后修复代码再运行,你实际上可以添加一个--watch命令。

同样,在package.json里的tsc命令中,你可以添加另一个标志--watch。当你添加--watch时,TSC会以与Nodemon非常相似的方式运行:它会编译你的代码,尝试进行类型检查,并发现问题。但假设我正在修改我的代码并尝试修复它们,TSC会监听文件系统并相应地重新检查。

所以这是两个非常有用的东西,因为你不需要不断地重新运行终端,你可以让这两个东西一直运行。

你可能会再次问一个问题:为什么我们需要两个独立的东西?为什么我们不直接设置一个流程,如果有类型错误,服务器就崩溃?答案是,你可以这样做,我们可以很容易地为你们设置。但这并不是很好的开发风格,因为代码中出现类型错误是常见的,就像代码风格不好一样。这只是开发过程的一部分。无论是每分钟、每10分钟、每小时还是每天,你都会回来清理你的代码。

所以通常,你可能会发现你不会经常看TypeScript检查,你只会继续开发你的工作,然后可能在合并请求之前,你运行tsc,看到一大堆错误,然后回去修复你的代码。这就是watch功能真正非常有帮助的地方。

总结

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

  1. API和Web API的核心概念:API是软件暴露的接口,Web API是基于HTTP协议的API,通过URL和JSON进行通信。
  2. RESTful API与HTTP方法:了解了GET(读取)、POST(创建)、PUT(更新)、DELETE(删除)四种HTTP方法及其在构建API时的约定用途。
  3. 构建Express服务器:实践了如何使用Node.js和Express框架创建能处理不同HTTP方法和路由的服务器,包括如何通过request.query获取URL参数,以及通过request.body获取POST/PUT请求的JSON数据。
  4. 与服务器交互的工具
    • API客户端(如ARC):用于手动测试API端点。
    • NPM库(如sync-request):允许在代码中程序化地发出HTTP请求,这对于编写自动化测试至关重要。
  5. 编写HTTP测试:学习了如何使用Jest和HTTP请求库为服务器端点编写测试,这是迭代二的核心任务。
  6. 代码抽象与调试:探讨了如何通过创建辅助函数来抽象HTTP请求逻辑,以提高代码复用性和可读性,并实践了调试HTTP服务器和测试的常见流程。
  7. 开发效率工具:介绍了Nodemon(自动重启服务器)和tsc --watch(实时TypeScript类型检查)这两个能显著提升开发体验的工具。

迭代二的核心是将迭代一中实现的函数逻辑包装成HTTP接口,这是一个非常可控的过程。请记住利用好提供的工具和导师的帮助。

祝大家在迭代二中顺利!

015:持久化 📚

在本节课中,我们将要学习一个核心概念:持久化。我们将探讨为什么应用程序需要保存数据,以及如何通过一种简单直接的方式——将数据写入文件——来实现数据的持久存储。这对于确保应用状态在服务器重启后依然存在至关重要。


数据与持久化

上一节我们介绍了数据的基本概念,本节中我们来看看如何让数据“持久化”。

在计算领域,数据是程序收集和处理的原始信息。例如,用户点击按钮的记录或游戏中的角色位置。然而,存储在程序变量中的数据是易失性的——当程序(如我们的Express服务器)停止运行时,这些数据就会丢失。

持久化的目标就是让数据能够超越创建它的进程的生命周期而持续存在。这就像游戏中的“保存”功能,让你下次打开游戏时可以继续之前的进度。

对于我们的项目,我们不要求使用复杂的数据库(如SQL)。相反,我们将实现一个简单的文件数据库。核心思路是:将程序的状态(一个JavaScript对象)转换为JSON字符串,然后写入一个本地文件。当程序再次启动时,从该文件读取数据并恢复状态。


实现持久化:一个简单示例

让我们通过一个具体的Express服务器示例,来演示如何实现数据的保存与加载。

我们将创建一个简单的服务器,它维护一个包含 xy 坐标的数据对象,并提供GET和POST路由来读取和修改它。

初始服务器(无持久化)

首先,我们创建一个没有持久化功能的服务器。数据仅存在于内存中。

import express from 'express';
const app = express();
app.use(express.json());

// 程序状态,存储在内存中
let data = { x: null, y: null };

// GET 路由:返回当前数据
app.get('/data', (req, res) => {
  res.json({ x: data.x, y: data.y });
});

// POST 路由:更新数据
app.post('/data', (req, res) => {
  const { inputX, inputY } = req.body;
  data.x = inputX;
  data.y = inputY;
  res.sendStatus(200);
});

app.listen(3001, () => console.log('Server running on port 3001'));

运行此服务器时,你可以通过POST请求更新数据,并通过GET请求查看。但一旦服务器重启,data 对象会被重置,所有更改都会丢失。

添加持久化功能

现在,我们分两步来添加持久化功能:保存数据到文件从文件加载数据

1. 保存数据到文件

我们需要在每次修改数据后,将整个 data 对象写入一个文件(例如 database.json)。

以下是实现此功能的关键步骤:

  • 导入Node.js的文件系统模块 (fs)。
  • 创建一个 save 函数,将 data 对象转换为JSON字符串并写入文件。
  • 在POST路由处理程序的末尾调用 save 函数。
import express from 'express';
import fs from 'fs'; // 导入文件系统模块
const app = express();
app.use(express.json());

let data = { x: null, y: null };
const dataFile = 'database.json';

// 保存数据到文件的函数
function save() {
  const jsonString = JSON.stringify(data);
  fs.writeFileSync(dataFile, jsonString);
}

app.get('/data', (req, res) => {
  res.json({ x: data.x, y: data.y });
});

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/d341ce4e296d3cc2573418767c12c823_11.png)

app.post('/data', (req, res) => {
  const { inputX, inputY } = req.body;
  data.x = inputX;
  data.y = inputY;
  save(); // 数据修改后,立即保存到文件
  res.sendStatus(200);
});

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/d341ce4e296d3cc2573418767c12c823_13.png)

app.listen(3001, () => console.log('Server running on port 3001'));

现在,每次执行POST请求后,当前状态都会被保存到 database.json 文件中。

2. 从文件加载数据

服务器启动时,它应该尝试从文件中读取之前保存的状态,并用其初始化 data 对象。

关键点在于处理文件可能不存在的情况(例如服务器第一次运行)。我们可以使用 fs.existsSync 方法来检查文件是否存在。

import express from 'express';
import fs from 'fs';
const app = express();
app.use(express.json());

const dataFile = 'database.json';
let data = { x: null, y: null }; // 默认值

// --- 服务器启动时,尝试从文件加载数据 ---
if (fs.existsSync(dataFile)) {
  const dbString = fs.readFileSync(dataFile);
  data = JSON.parse(dbString.toString());
  console.log('Loaded data from file:', data);
} else {
  console.log('No existing data file found, starting fresh.');
}
// ---------------------------------------

function save() {
  const jsonString = JSON.stringify(data);
  fs.writeFileSync(dataFile, jsonString);
}

// ... GET 和 POST 路由保持不变 ...

app.listen(3001, () => console.log('Server running on port 3001'));

最终效果

通过以上修改,我们实现了一个具有基本持久化功能的服务器:

  1. 首次运行database.json 文件不存在,data 使用默认值 {x: null, y: null}
  2. 修改数据:通过POST请求更新坐标,数据被自动保存到文件。
  3. 重启服务器:服务器启动时检测到 database.json 文件,读取并解析其中的JSON,用保存的数据恢复 data 对象。
  4. 读取数据:GET请求将返回持久化后的数据,而不是初始值。

这样,应用程序的状态就成功地在服务器重启后保留了下来。


关于代码结构的思考

在上面的例子中,我们在每个会修改数据的路由处理函数末尾都手动调用了 save()。你可能会想,是否可以创建一个“包装函数”来自动完成保存,以避免重复代码?

例如,创建一个 requestPost 函数来封装路由创建和保存逻辑:

function requestPost(route, handler) {
  app.post(route, (req, res) => {
    handler(req, res);
    save(); // 自动调用保存
  });
}

// 使用方式
requestPost('/data', (req, res) => {
  const { inputX, inputY } = req.body;
  data.x = inputX;
  data.y = inputY;
  res.sendStatus(200);
});

请注意:这种抽象虽然减少了 save() 的重复书写,但也增加了代码的间接性和理解成本。对于初学者或团队项目,清晰性往往比极致的代码精简更重要。在决定是否采用此类模式时,需要权衡其带来的便利性与对代码可读性的影响。


总结

本节课中我们一起学习了持久化的核心概念与简单实现。

  • 核心概念:持久化意味着让数据在创建它的程序进程结束后依然存在。
  • 实现方法:对于本课程项目,我们采用文件数据库的方式。即利用 JSON.stringify()JSON.parse() 在JavaScript对象与JSON字符串之间转换,并通过Node.js的 fs 模块进行文件的读写。
  • 关键步骤
    1. 保存:在数据变更后,将其序列化并写入文件。
    2. 加载:在程序启动时,检查并读取文件,将数据反序列化以恢复状态。
    3. 容错:处理文件不存在的首次运行情况。

你现在已经掌握了为迭代二项目添加持久化支持所需的所有基础知识。记住,保持代码清晰易懂与实现功能同等重要。

016:异常处理 🚨

在本节课中,我们将学习一种处理程序中错误的更优雅、更健壮的方法——异常处理。到目前为止,我们主要通过返回错误对象等方式来处理错误,这是一种较为传统的方式。而异常是存在于大多数现代编程语言中的强大概念,它允许我们更优雅地从错误中恢复,使程序更加健壮。

为什么需要异常?

上一节我们介绍了传统的错误处理方式。本节中,我们来看看一个简单的程序示例,并分析其局限性。

我们有一个名为 justCrash 的程序,它提示用户输入一个数字并计算其平方根。如果输入的数字小于0,程序会打印错误信息并退出。

import promptSync from 'prompt-sync';
const prompt = promptSync({});

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/26c5d7975cd77b4ac458eb3afb3519a5_15.png)

function sqrt(x: number): number {
    if (x < 0) {
        console.error('Error: Input less than 0');
        process.exit(1);
    }
    return Math.pow(x, 0.5);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/26c5d7975cd77b4ac458eb3afb3519a5_17.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/26c5d7975cd77b4ac458eb3afb3519a5_18.png)

const input = prompt('Please enter a number: ');
console.log(sqrt(parseInt(input)));

这种方法的问题是,它直接导致程序崩溃,这不是良好的程序设计。我们可能会想,可以返回 null 或其他特殊值来代替崩溃。

function sqrt(x: number): number | null {
    if (x < 0) {
        return null;
    }
    return Math.pow(x, 0.5);
}

但这种方法存在局限性:从类型角度看可能变得复杂;如果 null 本身是一个有意义的返回值,就会产生混淆。

抛出异常

为了解决上述问题,我们引入“抛出异常”的概念。以下是修改后的 sqrt 函数:

function sqrt(x: number): number {
    if (x < 0) {
        throw new Error('Error: Input less than 0');
    }
    return Math.pow(x, 0.5);
}

关键字 throw 用于“抛出”一个异常。new Error(‘...’) 创建了一个包含错误信息的异常对象。当这行代码执行时,程序的正常流程会被中断。

运行这个程序,当输入负数时,程序会崩溃,并在控制台输出详细的错误堆栈信息。这与直接退出相比,提供了更丰富的调试信息。

捕获和处理异常

仅仅抛出异常还不够,我们还需要能够“捕获”并处理它们,这才是异常机制价值所在。为此,我们使用 try...catch 语句块。

try {
    // 尝试执行可能会抛出异常的代码
    const input = prompt('Please enter a number: ');
    console.log(sqrt(parseInt(input)));
    console.log(‘Third line’); // 如果上一行抛出异常,这行不会执行
} catch (error) {
    // 当try块中的代码抛出异常时,跳转到这里执行
    console.error(‘There was an error:’, error.message);
}
console.log(‘Done’);

以下是 try...catch 的工作流程:

  • try 块中的代码会正常执行。
  • 如果 try 块中的任何代码抛出了异常,程序会立即停止执行 try 块中剩余的代码,并跳转到对应的 catch 块。
  • catch 块接收被抛出的异常对象(通常命名为 error),并处理这个错误。
  • 无论是否发生异常,catch 块之后的代码(‘Done’)都会继续执行。

这种“中断-跳转”的机制,避免了使用返回值时需要在每一步进行错误检查的繁琐。

在循环中使用异常

我们可以结合循环,实现更友好的用户交互,例如持续要求用户输入,直到输入有效。

let success = false;
while (!success) {
    try {
        const input = prompt(‘Please enter a number: ‘);
        console.log(sqrt(parseInt(input)));
        success = true; // 只有未抛出异常时才会执行到这里
    } catch (error) {
        console.error(‘Invalid input, try again.’);
    }
}

异常对象与测试

被捕获的 error 对象包含了异常的详细信息。通常,我们可以通过 error.message 属性获取创建异常时传入的描述字符串。

在编写单元测试时,我们也需要测试函数是否会按预期抛出异常。以下是使用 Jest 测试框架的示例:

import { sqrt } from ‘./sqrt’;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/26c5d7975cd77b4ac458eb3afb3519a5_48.png)

describe(‘sqrt function‘, () => {
    test(‘sqrt of positive number‘, () => {
        expect(sqrt(4)).toBe(2);
    });

    test(‘sqrt throws error for negative input‘, () => {
        // 注意:我们需要传递一个函数,而不是函数调用的结果
        expect(() => sqrt(-2)).toThrow(‘Error: Input less than 0‘);
    });
});

测试异常的关键是:expect 接受一个函数(例如 () => sqrt(-2)),然后检查执行该函数时是否抛出了特定信息的异常。如果直接传递 sqrt(-2) 的结果,异常会在测试设置前就被抛出,导致测试失败。

总结

本节课中我们一起学习了异常处理的核心概念。我们了解到,异常是一种中断程序正常流程的机制,用于优雅地处理运行时错误。通过 throw 关键字可以主动抛出异常,而 try...catch 语句块则用于捕获和处理这些异常,防止程序崩溃。异常机制避免了通过返回值传播错误的繁琐,并通过异常对象携带详细的错误信息。最后,我们还学习了如何在单元测试中验证函数是否正确地抛出了异常。掌握异常处理将使你编写的软件更健壮、更易于维护。

017:可维护性设计 🛠️

在本节课中,我们将要学习如何设计易于维护的软件。我们将探讨为什么可维护性至关重要,以及通过哪些具体方法可以提升代码的可维护性。

概述

软件工程不仅关乎构建正确的软件,更关乎构建能够随时间推移、在人员变动和需求变化中依然保持健壮的软件。本节课的核心是“可维护性设计”,我们将学习如何编写不仅功能正确,而且结构优雅、易于长期维护的代码。


为什么我们关心可维护性? 🤔

上一节我们介绍了课程主题,本节中我们来看看为什么可维护性如此重要。

在现实世界中,软件几乎总是在不断变化。几乎没有哪个被广泛使用的软件在构建完成后就一成不变。如果软件设计拙劣、难以维护,那么当它被迫进行更改时,就会变得难以修改。

“难以修改”意味着什么?它通常意味着:

  • 引入更多错误。
  • 需要花费更多金钱和时间。

软件之所以会频繁变化,原因有很多:

  • 不同的人员参与开发:不同的人带来不同的编程风格和理解。
  • 需求随时间变化:例如,YouTube 为了应对 TikTok 的竞争而增加了“Shorts”功能。
  • 需要提升性能:随着用户增长,代码需要优化以处理更大负载。
  • 修复错误:每次修复错误本身就是在修改软件。

代码量总是在增长,永远不会减少。因此,我们必须准备好适应变化。可维护性甚至比性能更重要,因为难以维护的代码会随着每次修补而累积性能问题。相反,易于维护的代码通常模块化更好、分离更清晰,因此也更容易进行性能优化。


如何使软件更易于维护?

理解了可维护性的重要性后,本节我们来看看提升可维护性的几个关键方法。

以下是几种主要途径:

  1. 测试:测试能让我们更有信心地修改代码,而不必担心破坏现有功能的正确性。一套完善的测试套件是安全进行更改的基石。
  2. 高质量的系统设计:这是在编写代码之前进行的规划工作,例如通过思维导图、概念图等方式,理清系统组件(如用户、频道)如何交互。这有助于在编码前建立良好的整体架构视图。
  3. 代码设计:这是本节课的重点,关乎如何编写清晰、优雅、易于理解的代码。它发生在你编写测试之后、实际编写实现代码之前的那段思考过程中。

需要记住的是,我们天生倾向于不编写设计良好的代码,因为通常时间紧迫、压力巨大。然而,良好的代码设计只需要多花一点时间和精力去深思熟虑,其长期回报是巨大的。


通过提问来指导代码设计 ❓

与其罗列一堆抽象的设计原则,不如通过向自己和团队提问来指导设计决策。问题能促进思考和对话。以下是七个关键的设计问题:

问题一:是否存在单一事实来源?

这是软件设计中最古老的原则之一。如果你在多处重复定义了相同的值或逻辑,那么当需要更改时,你就必须在所有地方进行修改,这极易出错。

这个问题的反面就是著名的 “不要重复自己” 原则。其核心思想是:系统中的每一处知识(或逻辑)都应该有一个单一、明确、权威的表示。

代码示例
假设我们有一段代码,其中硬编码了数字 1020 作为循环的边界,并且类似的逻辑块出现了两次。

// 初始代码(存在重复)
for (let i = 10; i < 20; i++) {
    console.log(i ** 2);
}
// ... 其他地方又有一段非常相似的循环
for (let i = 10; i < 20; i++) {
    console.log(i ** 3);
}

我们可以通过定义常量、提取公共函数来消除重复,确保循环边界和幂运算逻辑只有一个定义来源。

// 改进后的代码(单一事实来源)
const LOOP_START = 10;
const LOOP_END = 20;

function printPowers(power) {
    for (let i = LOOP_START; i < LOOP_END; i++) {
        console.log(i ** power);
    }
}

printPowers(2);
printPowers(3);

思考:甚至有人建议将 process.exit(1) 也提取成一个 exit() 函数以避免重复。这符合 DRY 原则,但有时会与“遵循标准约定”的原则产生权衡,我们将在问题七讨论。


问题二:这是否尽可能简单?

可维护的软件往往是简单的。经验丰富的程序员通常能写出更简单、更易理解的代码。初学编程时,我们可能以编写复杂代码为荣,但这通常会导致软件难以维护。

一个核心思想是:每一行你没有写出的代码都是没有错误的。因此,将代码视为一种负担,尽可能保持简洁。

保持简单的一个基本方法是:使用现有的库。在尝试自己解决问题之前,先问问“是否已经有人解决了这个问题?” 很可能存在一个经过充分测试、功能强大的库,使用它可以极大地简化你的代码,减少错误。

代码示例:生成一个最多50个字符的随机字符串(包含大小写字母)。

  • 复杂方式:自己编写循环、随机数生成和字符映射。
  • 简单方式:使用 random-expression 或类似的库。

// 使用库的简单方式
import RandomExpression from 'random-expression';
const regex = new RandomExpression('[A-Za-z]{50}');
const randomString = regex.generate();
console.log(randomString);


问题三:软件是过度设计还是设计不足?

这是一个很难把握平衡的问题。

  • 过度设计:意味着为了一些琐碎的变化,创建了过于复杂和抽象的架构。这会使代码难以理解和维护。
  • 设计不足:意味着没有为未来的变化预留足够的灵活性,导致后续需要不断重构和打补丁,同样会引入错误。

示例:一个记录用户存款和取款的系统。

  • 设计不足:只存储每笔交易记录。每次查询用户余额都需要实时计算所有交易的总和。在用户量大时,这可能成为性能瓶颈。
  • 过度设计:除了交易记录,还额外维护一个“当前余额”的缓存字段。这增加了复杂性,因为每次交易都需要同步更新这个字段,否则会导致数据不一致。

正确的选择取决于对系统规模、性能要求和未来变化的预测,这往往需要经验来判断。


问题四与五:相关的模块是否保持紧密?不相关的模块是否保持松散?

这两个问题引出了软件设计中的两个核心概念:耦合度内聚性

  • 耦合度:衡量软件不同组件之间的相互依赖程度。耦合度越低越好。
  • 内聚性:衡量一个模块内部各元素之间关联的紧密程度。内聚性越高越好。

理想的设计是:

  • 高内聚:将相关的功能(如 authRegisterauthLogin)放在同一个模块或文件中。这样,当需要修改认证逻辑时,所有相关代码都在一处,易于维护。
  • 低耦合:将不相关的功能(如 authchannels)清晰地分离开。这样,修改频道功能时,不会意外影响到认证模块。

遵循这个原则可以避免“意大利面条式代码”,使系统结构清晰,易于修改。


问题六:我是否在推测这项功能的必要性?

有时,我们会编写一些最终从未用到的代码,特别是在喜欢预先详细规划的情况下。

一个相关的原则是 “你不会需要它”。意思是:除非确定某项功能最终会被使用,否则程序员不应该添加它。这有助于消除不必要的代码。

一种有效的方法是采用 “自上而下” 的编码方式。从最顶层的、用户可见的功能开始实现(例如 investMoney),然后像树状搜索一样,逐层向下实现所需的子功能。这样,你只编写真正被调用的代码,避免了为“可能有用”的功能进行不必要的编码。

示例:编写一个函数,给定两个经纬度,计算如果现在出发,到达目的地的时间。

  1. 先定义顶层函数框架:timeBetweenPoints(lat1, lon1, lat2, lon2)
  2. 在函数体内,逻辑分解为:距离 = calculateDistance(...)速度 = getLocalSpeed(...)时间 = 距离 / 速度
  3. 此时 calculateDistancegetLocalSpeed 还不存在,但你的主函数逻辑已经清晰。接下来再去实现或查找这两个子函数。
  4. 这种方法确保你编写的每一行代码都是为了解决当前明确的问题。

问题七:这遵循标准约定吗?

程序员有时喜欢用自己的方式做事,但遵循社区或语言的标准约定能极大提高代码的可维护性。

当其他有经验的程序员阅读你的代码时,如果他们看到的是熟悉的模式和命名(例如循环变量用 ij,用 process.exit(1) 表示错误退出),他们就能更快地理解,减少困惑和犯错的可能性。

回顾问题一的例子:将 process.exit(1) 提取成自定义的 exit() 函数。

  • 优点:符合 DRY,避免了重复。
  • 缺点process.exit(1) 是 Node.js 程序员熟知的标准模式。自定义的 exit() 函数反而增加了认知负担,别人需要查看其实现才能理解。
  • 权衡:在这种情况下,为了遵循标准约定和保持代码的清晰度,轻微的重复可能是可以接受的。

因此,在追求“不重复”和“简单”的同时,也要问自己:“这是大家通常的做法吗?”


重构:持续改进设计 🔄

关于代码设计的讨论并不会因为软件“完成”而结束。没有人第一次就能写出完美的代码。

重构 是指在不改变代码外部行为的前提下,重新调整其内部结构的过程。它通常是为了改进糟糕的代码设计。

重要警告:如果没有完善的测试套件,应谨慎进行重构。盲目的重构很容易引入新的错误。测试套件能为你提供安全网,确保重构没有破坏任何现有功能。


总结

本节课中我们一起学习了软件可维护性设计的核心思想。我们了解到可维护性对于软件长期成功至关重要,并探讨了通过测试、系统设计和代码设计来提升可维护性的方法。

我们重点介绍了通过七个关键问题来指导代码设计:

  1. 是否存在单一事实来源?(DRY原则)
  2. 这是否尽可能简单?(KISS原则,善用库)
  3. 软件是过度设计还是设计不足?(需要经验平衡)
  4. 相关的模块是否保持紧密?(高内聚)
  5. 不相关的模块是否保持松散?(低耦合)
  6. 我是否在推测这项功能的必要性?(YAGNI原则,自上而下开发)
  7. 这遵循标准约定吗?(降低他人理解成本)

最后,我们提到了重构是持续改进代码设计的必要过程,但需在测试的保护下进行。掌握这些思维框架,并不断通过实践积累经验,你将能够编写出不仅正确而且优雅、易于长期维护的软件,这将成为你在开发领域中的强大优势。

018:代码覆盖率 📊

在本节课中,我们将要学习代码覆盖率。这是关于代码正确性的最后一个主题,我们将探讨如何衡量测试的全面性,以及如何利用自动化工具来发现我们代码中未被测试覆盖的部分。


概述

到目前为止,我们编写测试时主要依赖自己的想法。但我们需要更自动化的方法来评估测试的质量。这类似于我们之前学习代码风格检查工具(Linting)的过程:我们先手动学习什么是好的风格,然后使用工具来自动化这个过程。今天,我们将学习一种机制,用于衡量我们的测试实际覆盖了多少代码。

什么是覆盖率?

覆盖率是衡量我们测试全面性的一个指标。在项目中,我们自动评估的是你的代码实现,而不是测试本身的质量。助教们会查看你的测试代码,并根据它们覆盖了多少用例来给出部分分数。这实际上就是在评估测试覆盖率。

覆盖率主要分为两种类型:

  1. 测试覆盖率:这是一种衡量方式,用于检查测试覆盖了多少功能集。这通常是人工完成的,助教通过查看你的测试代码和规格说明书来评估。
  2. 代码覆盖率:这是我们本节课讨论的核心。它衡量的是在测试过程中执行了多少代码。这是一个可以完全自动化的过程,工具会逐行检查你的实现代码,看是否有测试执行过它。

理解代码覆盖率

想象一下,你有一段100行长的代码。代码覆盖率工具会检查,在你运行所有测试后,这100行代码中的每一行是否至少被一个测试执行过。

我们通常用一个简单的百分比来衡量代码覆盖率:被执行的代码行数占总代码行数的比例。但更重要的是,覆盖率工具能告诉我们哪些代码行从未被执行。这通常就是潜在bug的藏身之处。

实践代码覆盖率

让我们通过一个简单的例子来实践。假设我们有一个移除字符串中元音字母的函数 removeVowels

以下是该函数的初始实现:

export function removeVowels(string) {
    return string.replace(/[aeiou]/g, '');
}

我们为其编写了一些测试。运行测试后,它们都通过了。但如何知道测试是否全面呢?这时就需要使用代码覆盖率工具。

在Jest中,我们可以通过添加 --coverage 标志来运行覆盖率检查。例如,在 package.json 的脚本中添加:

"scripts": {
    "test:coverage": "jest --coverage"
}

然后运行 npm run test:coverage。输出结果不仅会显示测试通过与否,还会生成一份覆盖率报告。

分析覆盖率报告

覆盖率报告通常会显示几个关键指标:

  • 语句覆盖率:已执行的语句占总语句的百分比。
  • 分支覆盖率:已执行的分支(如if/else路径)占总分支的百分比。
  • 函数覆盖率:已调用的函数占总函数的百分比。
  • 行覆盖率:已执行的代码行占总代码行的百分比。

报告还会明确指出哪些代码行未被覆盖。例如,如果我们修改 removeVowels 函数,增加一个对输入字符串包含数字时返回 undefined 的逻辑:

export function removeVowels(string) {
    const pattern = /[0-9]/;
    if (pattern.test(string)) {
        return undefined; // 新增的代码行
    }
    return string.replace(/[aeiou]/g, '');
}

如果我们之前的测试都没有测试包含数字的字符串,那么运行覆盖率检查后,工具会报告新增的 if 语句块内的代码(return undefined;)未被覆盖。这提示我们需要增加相应的测试用例。

更复杂的例子:闰年判断

让我们看一个更复杂的例子:一个判断闰年的函数 isLeapYear。其逻辑基于以下规则:

  1. 不能被4整除的年份不是闰年。
  2. 能被4整除但不能被100整除的年份是闰年。
  3. 能被100整除但不能被400整除的年份不是闰年。
  4. 能被400整除的年份是闰年。

如果我们只测试了部分年份(例如2000, 2004, 2100),覆盖率报告可能会显示某些代码行(例如处理“能被400整除”的逻辑分支)未被测试。这指引我们去编写测试用例,例如测试年份2400,以确保所有逻辑路径都被执行。

查看详细的覆盖率报告

除了命令行输出,Jest在运行 --coverage 后还会在项目目录下生成一个 coverage 文件夹。其中 lcov-report/index.html 文件是一个可视化的覆盖率报告。你可以在浏览器中打开它,它会以更清晰、交互性更强的方式展示每个文件的覆盖率详情,并用颜色高亮显示未被覆盖的代码行。

在Web服务器项目中使用覆盖率

对于像我们项目中的Web服务器,应用代码覆盖率的原理是相同的。在迭代3的规格说明书中,我们会提供具体的命令(例如使用 ts-node --coverage 来运行服务器,并结合 jest --coverage 运行测试),以确保在服务器环境下也能正确收集覆盖率数据。具体操作将在相关实验和迭代说明中详细给出。

分支覆盖率简介

之前我们主要讨论的是语句覆盖率行覆盖率。还有一种更精确的度量叫分支覆盖率。它关注的是代码中每个条件判断(如 ifelseswitch、循环)所产生的不同路径是否都被测试到。

例如,一个简单的 if-else 语句有两个分支。即使你覆盖了 ifelse 代码块内的所有行,但如果你的测试从未让条件判断的结果为 false 从而进入 else 分支,那么分支覆盖率就不会是100%。

分支覆盖率通常能更准确地反映测试的完备性。不过,由于Jest在分支覆盖率计算上的一些特定行为,在本课程中,我们主要关注和使用语句/行覆盖率作为评估标准。


总结

本节课中我们一起学习了代码覆盖率。我们了解到:

  • 代码覆盖率是一个自动化工具,用于衡量测试执行了多少实现代码。
  • 使用Jest时,可以通过 jest --coverage 命令来生成覆盖率报告。
  • 关注未被覆盖的代码行比单纯追求高百分比更重要,因为这些地方往往是bug的源头。
  • 覆盖率报告可以帮助我们查漏补缺,编写更全面的测试用例。
  • 虽然分支覆盖率是更精确的指标,但本课程主要依据语句覆盖率进行评估。

掌握代码覆盖率将帮助你编写出更健壮、可靠的软件,是软件工程实践中一项非常重要的技能。

019:概念建模 🧠

在本节课中,我们将要学习概念建模。概念建模是软件设计中的一个关键环节,它帮助我们将复杂的系统想法转化为高层次、易于理解的可视化表示。我们将探讨什么是概念模型,并深入其中一种特定类型——状态图。


什么是模型?

你之前听说过“模型”这个词。模型可以是火车模型、数学模型,甚至是T台模特。但在软件工程中,模型指的是对复杂事物的简化表示,旨在帮助理解。

一个概念模型是一种特定类型的模型,它以概念性的、而非物理性的方式捕捉系统。它们通常是图示化可视化的。例如,高中时学过的原子结构图就是一个概念模型,它用简化的方式展示了原子的构成。

概念模型主要分为两类:结构型行为型


结构型概念模型

上一节我们介绍了概念模型的基本定义,本节中我们来看看结构型概念模型。这类模型强调系统的静态结构,展示系统如何存在,但不描述其行为。

以下是两种常见的结构型概念模型:

  • 类图:用于可视化展示软件项目中的各个文件(如 auth.ts, channels.ts)以及它们之间的关系。你可以将其理解为描述软件功能和文件的一种方式。
  • 实体关系图:这是一种更正式、更强大的数据模型,常用于数据库设计。它展示了系统中存储的数据实体(如“用户”、“帖子”)以及它们之间的关联。


行为型概念模型

了解了描述系统静态结构的模型后,本节我们来看看行为型概念模型。这类模型强调系统随时间变化的动态行为

以下是两种标准的行为型概念模型:

  • 用例图/用户流程图:我们将在下周详细讨论。
  • 状态图:这是我们本节课的重点。状态图用于描述具有多个不同状态的系统(我们称之为状态机)如何在这些状态之间转换。


状态图详解

状态图是行为型概念模型的一种,它用图形化的方式表示一个具有多个状态的程序。其核心思想很简单:用圆圈(或任何一致的形状)代表系统的不同状态,用带标注的箭头表示状态之间的转换

一个简单的例子是卧室的门。门的基本状态可以简化为“开”和“关”。从“开”状态,你可以执行“关闭”动作转换到“关”状态;反之亦然。模型的意义在于简化,现实中的门可能有“半开”、“锁住”等更多状态,但根据建模目的,两个状态的简化模型可能就足够了。

另一个例子是停车计时器。它的状态可能包括“输入车位号”、“输入停车时长”、“投币”、“确认”等。用户的不同操作(如点击按钮、投币、超时)会触发在这些状态间的转换。

构建状态图的一个关键挑战是决定:某个特定情况(例如,投入了无效硬币)应该被表示为一个独立的状态,还是仅仅作为状态间转换的一个动作/条件?这通常没有标准答案,取决于建模的具体目标和所需的细节层次。


实践练习:为悉尼公交卡系统建模

为了加深理解,让我们尝试为悉尼的公交卡系统建模。这个系统涉及“拍卡进站”、“拍卡出站”等操作。

我们可以从“未拍卡”这个状态开始。从该状态,用户可以执行“拍卡”动作。但接下来问题变得复杂:

  • 拍卡后,如果资金不足怎么办?
  • 拍卡进站后,如果在15分钟内在同一站点拍卡出站,则不收费。这如何表示?
  • 拍卡出站后一小时内再次拍卡进站,行程会被合并。这又该如何建模?

这个练习表明,状态图擅长建模流程的有效性(例如,你是否在有效的行程中),但在表示具体的财务计算(如扣费金额)方面可能不那么直接。这再次说明了模型是有侧重点的简化,我们需要根据沟通目标来选择合适的细节粒度。


一个有趣的案例:世界太阳能车挑战赛

最后,我们通过一个有趣的案例来看看概念建模的强大应用。在世界太阳能车挑战赛中,车队需要驾驶太阳能车从澳大利亚北端到南端。

一个核心问题是:如何决定行驶速度?开太快耗电快,可能无法到达终点;开太慢又会输掉比赛。一名学生在2015年为此设计了一个概念模型,并将其编写成了代码。

这个模型将3000公里的旅程分割成约10000个小段(例如每段30米)。对于每一小段,程序会基于简化假设(如该段道路坡度恒定、阳光强度恒定)来计算汽车的能耗。然后,程序可以模拟不同速度策略下的总能耗,从而帮助找到最优速度。

这个模型忽略了轮胎温度、湿度等复杂因素,但它通过采样简化现实世界,成功地将一个极其复杂的问题变得可计算、可优化,完美诠释了概念建模的精髓。


总结

本节课中我们一起学习了概念建模。我们了解到模型是对复杂事物的简化表示,而概念模型则侧重于以图示化的方式进行这种简化。我们区分了结构型行为型概念模型,并深入探讨了状态图这一种行为型模型。通过门、停车计时器、公交卡系统以及太阳能车挑战赛等例子,我们看到了如何用状态图描述系统的不同状态和转换,也理解了建模本质上是一种有目的的、选择性的简化过程,其价值在于帮助理解、沟通和设计复杂的软件系统。

020:部署 🚀

在本节课中,我们将要学习软件部署的核心概念。部署是让软件可供用户使用的关键环节。我们将探讨部署的历史演变、核心概念(如持续交付和持续部署),并通过简单的例子理解其基本原理。

概述

软件开发的最终目的是让人们能够使用它。如果软件不能被用户使用,其价值就无从体现。因此,部署——即让软件系统可供使用的相关活动——至关重要。本节课我们将从最简单的部署例子开始,逐步深入到现代部署的自动化流程和相关概念。

部署的定义与简单示例

部署可以总结为使软件系统可供使用的相关活动。其核心是让用户能够访问和使用你编写的软件。

为了直观理解,我们来看一个最简单的部署例子。假设你在CSE账户上创建了一个简单的HTML文件。

  1. 首先,创建一个文件夹和一个HTML文件。
    mkdir peanut
    cd peanut
    echo '<marquee>Welcome to my site</marquee>' > index.html
    
  2. 这个文件目前只能在你自己的机器上通过浏览器打开查看。
  3. 为了让其他人能在互联网上访问,你可以将其复制到CSE账户的 public_html 目录下。
    cp index.html ~/public_html/index.html
    # 可能需要调整文件权限
    chmod 644 ~/public_html/index.html
    
  4. 之后,任何人就可以通过访问 https://cgi.cse.unsw.edu.au/~zID/index.html 这个URL来看到你的网页了。

这个“复制文件”的过程,本质上就是一次部署。当你更新了 index.html 文件后,再次运行复制命令,就完成了新版本的部署。这个例子虽然简单,但清晰地展示了部署的核心思想:将编写好的代码放到一个可被公开访问的地方

部署方式的历史演变

上一节我们通过一个手动复制的例子理解了部署的基本概念。本节中我们来看看部署方式是如何随着时间变化的。

历史上,部署是一项频率很低且更偏物理性的活动。在21世纪初或更早的时候,代码通常会开发数天甚至数年而不进行测试和部署。例如,早期的电子游戏一旦刻录到光盘上发布,其中的Bug就可能存在几十年。

过去10到15年间,发生了两个重大变化:

  1. 互联网的普及:越来越多的软件基于互联网构建(如网站、Web应用),而非传统的桌面安装包。
  2. 网络连接与带宽的提升:高速互联网使得频繁地向用户推送软件更新成为可能。

这些变化催生了一个可以持续、频繁发布软件的行业。软件更新从每年一次(如旧版Windows)变成了每周甚至每天一次(如现代移动操作系统和网站)。这也促进了软件即服务(SaaS)模式的兴起,用户从“购买软件”转变为“订阅服务”。

如今,让软件可供使用,通常意味着将其部署到云服务提供商(如AWS、Google Cloud、Microsoft Azure)的服务器上,使其通过互联网可访问。你在实验5中使用的“always data”就是一个云服务提供商的例子。

从持续集成到持续交付

我们之前已经学习过持续集成(CI),它是自动化集成多位贡献者代码变更的过程。现在,我们将这个概念扩展。

现代部署为了实现快速的开发周期,采用了高度集成和自动化的方法。这就是持续交付

持续交付是自动化软件发布流程的过程。具体来说:

  • 持续集成自动化了代码变更的集成与验证(测试、linting)。
  • 持续交付则在此基础上,自动化了将已验证通过的代码发布到生产环境(或预备环境) 的流程。

在持续交付中,发布软件(部署)的流程本身是自动化的,但决定何时发布这一步通常仍是手动的。这就像有一个“大红色按钮”,需要人工批准后才能触发自动部署流程。许多公司会设定一个发布节奏,例如每天一次,在当天所有合并到主分支且通过验证的代码会在指定时间被部署。

发布策略与环境分级

对于个人项目,部署可能只是简单地从开发环境推到生产环境。但对于大型软件,直接发布给所有用户风险很高。因此,常见的做法是设置多级发布环境,逐步扩大发布范围。

以下是常见的环境分级策略:

  • 开发/测试环境:每次代码合并到主分支后自动更新,供内部团队测试。
  • 预发布/暂存环境:每天或每周从主分支拉取稳定版本,供质量保证(QA)团队或特定外部测试员进行更全面的测试。
  • 生产环境:经过前面环境验证后,最终发布给所有真实用户使用的环境。

这种分级发布策略就像一个风险控制漏斗。以微软Windows为例,新代码可能先部署给内部全体员工使用,发现问题并修复;然后推送给参与“Windows预览体验计划”的外部测试者;最后才向全体公众发布。这样既能快速获得反馈,又能避免有问题的版本影响大多数用户。

持续部署

持续部署是持续交付的一个更进一步的阶段。两者的核心区别在于决策的自动化程度:

  • 持续交付:部署流程自动化,但发布到生产的决策需要人工批准
  • 持续部署:部署流程自动化,并且发布到生产的决策也完全自动化。只要代码通过所有自动化测试(即CI流水线显示为绿色),就会自动部署到生产环境。

公式可以简单表示为:
持续部署 = 持续集成 + 自动化通过后直接部署

这减少了人为干预,可以实现更快的发布频率,但要求拥有极其可靠和全面的自动化测试套件。

开发运维与维护

随着部署频率的增加,开发(Dev)和运维(Ops)之间的界限变得越来越模糊,催生了DevOps文化。DevOps不是某个具体的职位,而是一种强调协作与自动化的理念,旨在让开发人员更多地参与部署、监控等运维工作,也让运维人员更深入地理解应用代码,从而共同优化软件的构建、测试和发布周期。

部署之后的下一个重要阶段是维护。维护主要包括两方面:

  1. 监控与保障:确保软件运行稳定、性能良好,及时处理错误和告警,保障用户体验。
  2. 分析与优化:通过分析工具了解用户如何与软件交互(例如,哪些功能最常用,用户在何处流失),这些反馈将为下一轮的需求分析、设计和开发提供依据,形成一个完整的迭代循环。

总结

本节课中我们一起学习了软件部署的完整图景。我们从部署的基本定义出发,通过一个手动复制文件的例子理解了其核心是“让软件可用”。我们回顾了部署方式从低频、物理介质到高频、基于互联网的云服务的历史演变。

我们深入探讨了现代部署的核心自动化理念:持续交付(自动化发布流程)和持续部署(自动化发布决策)。并了解了通过多级发布环境(如开发、预发布、生产)来管理发布风险的策略。最后,我们提到了促进开发与运维协作的DevOps文化,以及部署后至关重要的维护阶段(监控与分析),这使软件开发形成了一个持续改进的闭环。

掌握这些概念,能帮助你理解如何安全、高效地将你编写的代码交付到用户手中,这是软件工程实践中至关重要的一环。

021:需求介绍 📋

在本节课中,我们将要学习软件工程中一个至关重要的概念:需求。我们将探讨什么是需求、为什么需求如此重要,以及如何通过一个结构化的过程来获取和定义需求。

概述:什么是需求?

构建任何系统最重要的部分,是弄清楚你需要做什么。这不仅仅是编写代码或绘制漂亮的图表,而是真正理解你要实现的目标。当我们说“弄清楚需要做什么”时,我们实际上是在说,我们正在弄清楚为了达成目标,系统被要求做什么

根据IEEE的定义,需求是用户解决问题或达成目标所需的条件或能力。我们也可以将需求描述为所有利益相关者就待完成工作达成的一致协议,或者是对拟建系统的描述和约束。

“构建软件系统最困难的部分,是决定要构建什么。没有哪部分工作,如果做错了,会比这部分更能削弱最终的系统。”

为什么需求至关重要?

在编写任何代码之前,我们通常需要知道如何测试它,也需要知道如何设计它。然而,为了测试和设计任何东西,我们必须首先知道我们的目标是什么,更具体地说,我们必须知道我们正在构建的系统的需求是什么。

一个常见的比喻是建造一栋建筑。为什么开工前总是需要很长时间?因为大部分时间都花在了规划和确保他们在做正确的事情上。生活中几乎所有事情都是如此:最好的工程产品会花费大量时间确保它们解决了正确的问题,而花较少时间实际解决那个问题。

需求的类型:功能性与非功能性

需求作为描述系统的规则或陈述,通常可以分为两大类:功能性需求非功能性需求

  • 功能性需求:指定系统应提供的特定能力或服务。它描述系统“做什么”,可以将其视为系统的“技能”。
    • 公式/代码描述系统必须提供 [某项具体功能]
    • 示例:系统必须在有新帖子或有人评论现有帖子时,向所有用户发送通知。

  • 非功能性需求:对系统如何实现其功能施加约束。这通常是性能特征,可以看作是一种限制或方法。
    • 公式/代码描述系统必须在 [某种条件/约束] 下提供功能
    • 示例:系统必须在相关活动发生后30分钟内发送电子邮件。

关键区别:功能性需求描述“做什么”,而非功能性需求描述“做得怎么样”或“在什么条件下做”。非功能性需求通常会收紧或限定功能性需求的范围。

需求工程:让需求“诞生”的过程

需求不会凭空出现。在软件工程中,我们通常需要通过一个称为需求工程的过程来帮助它们“诞生”。这个过程可以大致分为四个步骤:获取、分析、规约和验证

上一节我们介绍了需求的类型,本节中我们来看看如何系统地获取和定义它们。

第一步:获取

获取是指通过提问、发现和学习来从相关人员那里提取信息。这类似于市场调研、问卷调查、焦点小组或利益相关者访谈。

  • 利益相关者:指那些会受到你所做事情影响的人(无论是正面、负面还是中性)。例如,在构建一个新的课程管理系统时,学生、导师和讲师都是利益相关者。

第二步:分析

在从人们那里获取了所有信息之后,你需要尝试组织和分析这些信息,识别事物之间的依赖关系、冲突、风险,并将它们归类。

这个过程就像制作思维导图,将原始信息结构化。我们通常通过编写用户故事用例(将在后续课程中介绍)来辅助分析。

第三步:规约

规约是指尝试将所有粗略的想法和工作成果,转化为更正式、简洁的文档。其目的是剔除冗余信息,进行综合、浓缩和细化,形成一份能够概括所有需求的草案。

示例:一个关于门锁的规约化需求可能是:“系统应始终保持门锁锁定,除非收到授权用户的解除指令。当锁被解除时,应启动倒计时;若倒计时结束时锁仍处于解除状态,则应自动重新锁定。”

第四步:验证

验证是将你整理好的需求文档带回给最初的利益相关者,并询问:“这是正确的吗?这听起来像我们当初想一起做的事情吗?” 如果得到肯定的答复,那么需求就得到了确认。

需求工程中的常见挑战

在尝试从人们那里建立一套需求时,你总会遇到一些问题。了解这些挑战有助于我们更好地应对。

以下是需求工程中可能遇到的一些主要挑战:

  • 需求在设计和构建开始后才被理解:有时人们只有在实际使用系统时,才知道他们想要什么规则。
  • 客户不知道他们想要什么:这很常见,客户可能无法清晰表达他们的需求。
  • 客户改变主意:需求可能会随着时间或认知的变化而改变。
  • 开发人员缺乏领域知识:如果开发人员不熟悉系统所涉及的专业领域(如教育、医疗),可能无法准确理解某些需求。
  • 难以接触利益相关者:有时可能很难联系到关键的利益相关者进行沟通,导致需要做出更多假设,从而增加出错风险。
  • 过早陷入细节或解决方案:这是一个非常普遍的问题,与“XY问题”密切相关。

警惕“XY问题”

“XY问题”是指:当你想解决问题X时,你认为解决方案Y可行,于是你没有询问关于X的问题,而是去问别人关于Y的问题。

经典示例:你需要吃午餐,但认为必须用叉子。你问朋友:“你有叉子吗?” 朋友说没有,于是你们花时间去找叉子。后来才发现,朋友有筷子,而筷子完全能满足你“需要餐具吃饭”的根本需求(X)。你错误地将“需要叉子”(Y)当成了问题本身。

在软件开发中,这经常发生。例如,新手开发者可能会问“如何安装某个复杂的库”,而经过长时间讨论后才发现,他们根本不需要那个库,用一个更简单的库就能实现他们的原始目标(X)。因此,始终要关注根本问题,而不是预设的解决方案。

总结

本节课中我们一起学习了软件工程需求的核心理念。我们了解到,需求是定义系统“做什么”和“如何做”的规则,是项目成功的基石。我们区分了功能性需求(描述能力)和非功能性需求(描述约束)。同时,我们介绍了需求工程的四个关键步骤:获取、分析、规约和验证,并探讨了在此过程中可能遇到的挑战,如“XY问题”。记住,花时间弄清楚正确的问题,往往比匆忙解决问题更重要。

022:用例与用户故事 📝

在本节课中,我们将学习如何以更人性化和结构化的方式表达软件需求。我们将重点介绍两种关键方法:用户故事用例。这些方法有助于将复杂的用户需求转化为清晰、可执行的开发指导。


概述

之前我们讨论了需求工程的高层概念,但未深入探讨如何具体表示系统或需求。本节将弥补这一空白,详细介绍用户故事用例。这两种工具能帮助我们更有效地捕捉和传达以用户为中心的需求,确保开发过程始终关注人的体验。


用户故事 📖

用户故事在敏捷开发世界中极为重要。它本质上是一种需求工程方法,旨在以用户为中心指导开发过程。用户故事的核心是采用特定结构来书写需求,确保每项需求都明确涉及用户角色、用户目标及其动机。

用户故事的基本结构

用户故事的标准格式如下:

作为 [用户类型],
我想要 [执行某个操作],
以便于 [实现某个目标或价值]。

例如,如果一名学生提出:“我希望能在网上购买月停车票。” 我们可以将其转化为用户故事:

作为学生,
我想要购买停车通行证,
以便于我能开车上学。

这种结构强调了三个关键部分:用户角色用户目标背后的原因。明确“原因”至关重要,因为它能帮助开发者理解需求的深层动机,避免在传递过程中丢失重要信息。

编写优质用户故事的要点

以下是编写用户故事时需注意的几个关键属性:

  • 以用户为中心:故事应聚焦于客户及其体验,而非产品功能。尽量避免写成功能规格说明。
  • 描述问题,而非解决方案:例如,应写“我想要感受到实现目标的动力”,而非“我想要一个显示进度的饼图”。
  • 独立性:各个用户故事应尽可能独立,以便单独实现和测试。
  • 可协商性:故事不应包含过多细节,以便为开发者和设计师留出创意空间。
  • 有价值:故事应对客户或用户具有明确价值。
  • 可估算:故事的实现难度应相对清晰,不能过于模糊。
  • 可测试:每个故事都应有明确的验证方法。

实践示例:待办事项应用

假设我们正在为一个待办事项应用收集需求。以下是一些可能的用户故事示例:

  • 示例1
    作为学生,
    我想要能够为待办事项设置优先级,
    以便于我能轻松看到哪些任务对我最重要。
    
  • 示例2
    作为学生,
    我想要能够用标签对任务进行分类,
    以便于我能轻松查看相关联的任务组,而不会因一堆未分类的任务感到不知所措。
    

用户验收标准 ✅

用户故事常与用户验收标准配套使用。验收标准是一种帮助测试用户故事是否得到满足的方法,它将一个用户故事分解为一系列必须满足的具体条件。

验收标准的形式

验收标准通常以清单形式书写,供人工测试时核对。例如,对于用户故事“作为用户,我想使用搜索字段输入城市名或街道名,以便找到匹配的酒店选项”,其验收标准可能包括:

  • 搜索字段位于顶部导航栏。
  • 用户点击“搜索”按钮后开始搜索。
  • 搜索字段包含灰色文本的占位符。
  • 用户开始输入时,占位符消失。

另一种更自由的写法是采用 “场景-给定-当-那么” 格式:

  • 场景:用户忘记了密码。
  • 给定:用户已导航到登录页面。
  • :用户选择“忘记密码”选项并输入有效邮箱。
  • 那么:系统应向该邮箱发送重置链接。

编写验收标准的要点

  • 详略得当:既不能太宽泛,也不能太狭窄。
  • 非技术性:应让非技术人员(如客户)也能理解。
  • 尽早确定:尽管开发过程中可以更新,但最好在开始时就和利益相关者达成一致。
  • 用于验收测试:通过执行验收测试来验证这些标准是否得到满足,这通常在专门的用户验收测试环境中进行。

用例图 🔄

上一节我们介绍了以叙事形式表达需求的用户故事。本节我们来看看另一种可视化需求的方法——用例。用例侧重于以视觉化或步骤化的方式描述系统应如何工作,特别是用户与系统之间的交互对话。

用例的两种形式

用例主要有两种表现形式:文本步骤列表视觉图表。两者都旨在清晰地传达用户如何与软件系统或业务流程互动。

1. 文本步骤列表
例如,描述从ATM取款的用例可能包括以下步骤:

  1. ATM提示客户输入PIN码。
  2. 客户输入PIN码。
  3. ATM向银行验证账户。
  4. 银行确认账户有效并返回余额。
  5. ATM询问客户需要什么服务。
  6. 客户请求提取60美元。
  7. ATM支付现金。
  8. ATM通知银行交易完成。

2. 视觉图表(用例图)
图表形式能更直观地展示交互流程。下图展示了一个简化的ATM取款交互序列:
(此处应有一个描述用户、ATM和银行之间消息传递的顺序图,显示“输入PIN”、“验证账户”、“请求取款”、“发放现金”、“通知银行”等交互步骤。)

视觉图表使复杂的交互流程一目了然,便于快速理解。

用例的核心价值

无论是文本还是图表,用例的核心在于:

  • 描述对话:展现用户操作与系统响应之间的序列。
  • 高层次与黑盒:关注外部行为,而非内部实现细节。
  • 改善沟通:是向不同背景的利益相关者传达系统行为的有效工具。

综合实践:校园单轨列车项目 🚝

让我们通过一个假设项目来综合运用所学知识:为大学校园设计一个单轨列车系统。

首先,我们需要从不同利益相关者(如学生、学校管理层、建设方)那里收集需求。以下是一些可能产生的需求示例:

  • 功能需求:系统应至少设有两个站点(例如,法学院图书馆下方和Scientia上方)。
  • 非功能需求:列车在两个站点间的运行时间应少于5分钟。
  • 用户故事
    作为学生,
    我想要能够免费乘坐单轨列车,
    因为步行既方便又免费。
    

通过与各方沟通,我们可以收集更多类似的用户故事和需求。随后,我们可以绘制用例图来描述乘坐单轨列车的完整流程,例如:刷卡进站、登上列车、列车运行、下车、刷卡出站等。

这个过程充分体现了需求工程的“人性化”一面——通过持续对话和验证,确保我们构建的是正确的东西。


总结

本节课我们一起学习了两种核心的需求表达技术:

  1. 用户故事:一种以“作为-我想要-以便于”结构编写的、以用户为中心的叙事性需求描述,强调用户的角色、目标和动机。
  2. 用例:一种通过步骤列表或序列图来描述用户与系统间交互过程的方法,有助于可视化系统行为。

这两种工具相辅相成,用户故事帮助我们理解“为什么”要构建某个功能,而用例则清晰地展示了用户“如何”与系统互动以实现该功能。掌握它们能极大地提升需求沟通的效率和准确性。

023:验证 🧪

在本节课中,我们将要学习软件工程中的一个重要概念:验证。我们将探讨验证与之前学过的“确认”有何不同,理解它们在软件开发过程中的角色,并了解如何通过用户验收测试来执行验证。


概述

验证是需求分析环节的最后一部分,虽然占比不大,但至关重要。“验证”这个词本身非常有趣,它与我们之前在第二周学习的“确认”属于不同的理解范畴。确认主要与动态测试相关。

上一节我们介绍了用户验收标准,本节中我们来看看验证如何帮助我们区分测试与满足用户需求之间的差异。


验证与确认的区别

为了更好地理解,让我们先比较一下这两个概念。以下是IEEE的标准定义:

  • 确认:在系统生命周期背景下,是一系列活动,将系统生命周期中的某个产品与该产品所需具备的特性进行比较。
  • 验证:(定义略,我们用一个更人性化的方式来理解)。

这些定义可能有些枯燥。实际上,我们可以这样简单区分:

  • 确认 关注的是 “系统是否被正确地构建了”
  • 验证 关注的是 “我们构建的是否是正确的系统”

这听起来可能像文字游戏,但意义重大。确认假设你正在构建正确的东西,并检查你是否正确地构建了它。而验证则更侧重于确保你构建的东西本身就是正确的。


一个具体的例子

让我们回顾之前提到的桌子例子。如果你制定了一系列需求,比如桌子必须有多大、必须能做什么。

  • 确认 是检查这张桌子是否满足了你的需求说明书上要求它做的事情。
  • 验证 则是更多地尝试确保你的需求本身实现了最初的目标。

当然,现实情况比这更复杂。需求往往不是非黑即白的,它们具有层次性。例如,高层需求可能是“桌子必须承重200公斤并使用可持续木材”。随着流程深入,工程师会制定更详细的需求,比如“桌子必须由某种特定木材制成,并具有某种漆面和公差”。

  • 确认 更侧重于:既然你假设做了正确的事(比如使用可持续木材),那就检查桌子是否真的用那种木材(比如“Treewood”)制成的。这就像编写Jest测试——你已经定义了系统,只是确保它被正确构建。
  • 验证 则是确保你交付给用户的桌子最终能让用户满意。即使你的所有Jest测试都通过了,用户可能仍然不喜欢它,认为这不是他们想要的东西。Jest测试通过是确认,而用户是否接受则是验证。

如何进行验证

那么,我们如何进行验证呢?我们已经讨论了很多关于确认的内容(比如用Jest、TypeScript)。验证软件并非一个可完全自动化的过程,因为它涉及人的因素。它通常是软件测试的最后一步,在我们完成了所有单元测试、集成测试和系统测试之后进行。

最常见的验证方法之一是让真实客户测试软件,或者让质量保证员工测试完整的软件。基本上,你编写软件后,可能会将其部署到互联网的某个地方,然后让组织内部全职负责确保软件工作的人员进行测试,或者交给真实客户并询问:“你觉得怎么样?这达到了你想要的效果吗?”

质量保证员工可能有一个检查清单。客户可能没有,但他们的反馈会被监控和询问,这本身也类似于一个检查清单。这一切都是为了确保软件按预期运行,并解决了最初的问题。


用户验收测试

这就是验证的过程,非常手动,非常依赖人力。这也让我们回到了上一讲提到的用户验收测试

用户验收测试的定义是:我们进行正式测试,以确保系统满足验收标准,从而判断用户是否接受该系统。即使你的Jest测试都通过了,用户对你交付的产品满意吗?

如前所述,这通常是黑盒测试,直接在客户身上进行测试。你经常会听到“UAT测试环境”这个说法,这有点有趣(因为UAT本身就代表用户验收测试)。例如,我们可能有主网站 polar.com,同时还有一个用于测试的网站 dev.polar.com


总结

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

  1. 验证的核心是确保“构建了正确的系统”,而确认是确保“系统被正确地构建”。
  2. 验证是一个以人为中心的过程,通常在自动化测试之后进行。
  3. 用户验收测试是执行验证的关键实践,通过让真实用户或QA人员依据验收标准测试完整系统,来评估软件是否真正解决了用户的问题并令其满意。

感谢学习。如果你有任何反馈,请随时提出。

024:身份验证 🔐

在本节课中,我们将要学习软件安全的基础知识,特别是身份验证和授权。我们将探讨如何安全地存储密码、使用哈希函数以及如何通过令牌机制保护用户会话。这些概念是完成项目第三阶段的关键。

概述:身份验证与授权

身份验证是验证用户身份的过程,例如输入用户名和密码。授权则是确定已验证用户拥有哪些访问权限的过程。虽然两者紧密相关,但本节课我们将主要聚焦于身份验证。

基本身份验证方法及其问题

上一节我们介绍了身份验证的基本概念。本节中我们来看看最常见的身份验证实现方法及其潜在的安全风险。

最常见的做法是:用户注册时,服务器存储其用户名和密码。当用户登录时,服务器将用户输入的密码与存储的密码进行比对。如果匹配,则验证通过。

以下是一个简化的代码示例:

function register(email, password) {
    // 将 email 和 password 存储到数据对象中
    dataStore.users.push({ email, password });
    return { success: true };
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/4a999000b3e5b67a409ca520302b7c81_11.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp1531-swe-fund/img/4a999000b3e5b67a409ca520302b7c81_13.png)

function login(email, password) {
    const user = dataStore.users.find(u => u.email === email);
    if (user && user.password === password) {
        return { success: true };
    }
    return { success: false };
}

这种方法存在一个严重问题:密码以明文形式存储。如果攻击者获取了存储数据的文件(例如 data.json),他们就能直接看到所有用户的密码。在真实的软件系统中,这会造成巨大的安全风险。

加密与哈希:隐藏信息

既然不能存储明文密码,我们需要一种方法来“隐藏”密码,同时又能验证用户输入的正确性。这就引出了加密哈希这两个核心概念。

两者都是将明文(如密码)转换为看似随机的字符串的过程。但关键区别在于:

  • 加密是可逆的。存在一个“密钥”可以将加密后的数据恢复为原始明文。
  • 哈希不可逆的。它是一种单向函数,无法从哈希值反推出原始输入。

哈希函数就像一个“搅拌机”,输入任何内容,都会输出一个固定长度的、看似无规律的字符串。例如,输入 hello 可能会得到类似 2cf24dba5fb0a30e... 的哈希值。

对于密码存储,我们需要的正是哈希这种不可逆的特性。我们不需要知道用户的原始密码,只需要验证他们输入的密码是否正确。

使用哈希安全存储密码

理解了哈希的原理后,我们来看看如何将其应用于密码存储。

核心思想是:存储密码的哈希值,而非密码本身

以下是更新后的流程:

  1. 注册时:当用户设置密码时,服务器立即计算该密码的哈希值,并将哈希值(而非原始密码)与用户邮箱一起存储。
    import { getHashOf } from './hashUtil';
    function register(email, password) {
        const hashedPassword = getHashOf(password); // 计算哈希
        dataStore.users.push({ email, password: hashedPassword }); // 存储哈希值
        return { success: true };
    }
    
  2. 登录时:当用户尝试登录时,服务器对他们输入的密码进行同样的哈希计算,然后将得到的哈希值与之前存储的哈希值进行比对。
    function login(email, password) {
        const user = dataStore.users.find(u => u.email === email);
        const inputHash = getHashOf(password); // 哈希用户输入的密码
        if (user && user.password === inputHash) { // 比对哈希值
            return { success: true };
        }
        return { success: false };
    }
    

这样,即使数据库泄露,攻击者也只能看到一堆哈希值,而无法直接获得用户的原始密码。

关于哈希与彩虹表的补充说明

虽然哈希是单向的,但攻击者仍可能使用“彩虹表”进行破解。彩虹表是一个预先计算好的、包含大量常见密码及其对应哈希值的数据库。如果用户密码很简单(如 123456),攻击者可以通过查表快速找到对应的原始密码。

因此,鼓励用户使用长且复杂的密码至关重要。密码的长度和复杂性会使得可能的组合数量呈指数级增长,从而让构建完整的彩虹表在物理上变得不可能。

令牌、会话与防篡改

在项目的第三阶段,除了哈希密码,还需要保护会话令牌。在之前的实现中,登录后返回的令牌可能只是一个简单的会话ID(如 123)。攻击者可能通过猜测或篡改这个ID来劫持其他用户的会话。

为了使令牌防篡改,我们可以采用签名机制:

  1. 登录时,服务器不仅生成一个会话ID(如 sessionId),还使用一个只有服务器知道的密钥,与会话ID组合后计算一个哈希值(即签名)。
    const sessionId = generateRandomId();
    const secret = 'my_server_secret';
    const sessionHash = getHashOf(sessionId + secret);
    const token = { sessionId, sessionHash }; // 返回给客户端
    
  2. 客户端在后续请求中携带这个完整的 token 对象。
  3. 服务器收到请求后,使用存储的密钥和收到的 sessionId 重新计算哈希值,并与客户端传来的 sessionHash 进行比对。如果匹配,说明令牌未被篡改。

这样,即使攻击者截获并修改了 sessionId,由于他不知道密钥,也无法生成正确的 sessionHash,因此篡改会被服务器发现。

在HTTP头中传递令牌

最后,我们需要改变令牌的传递方式。在之前的实现中,令牌可能通过URL查询参数(GET/DELETE请求)或请求体(POST/PUT请求)传递。

然而,URL查询参数在网络传输中通常是不加密的(明文),容易泄露。为了更安全,我们应该始终将令牌放在HTTP请求的头部中传递。

例如,在使用 axios 库时:

// 将 token 放在请求头中,而不是查询参数或请求体里
axios.get('/api/channel/details', {
    headers: {
        'token': JSON.stringify(userToken) // token 是一个包含 sessionId 和 sessionHash 的对象
    }
});

HTTP头部信息在HTTPS连接中是加密的,这为令牌提供了更好的保护。

总结

本节课中我们一起学习了软件安全的基础知识,重点聚焦于身份验证。我们探讨了以下核心内容:

  1. 哈希密码:使用不可逆的哈希函数存储密码,避免明文存储的风险。
  2. 令牌防篡改:通过结合密钥对会话ID进行哈希签名,确保令牌的完整性,防止会话劫持。
  3. 安全传输:将敏感令牌置于HTTP请求头部中进行加密传输,而非不安全的URL查询字符串。

这些实践是构建安全软件的基础,希望你能在项目第三阶段成功应用它们。

025:软件复杂度 📊

在本节课中,我们将学习软件复杂度的概念。我们将探讨两种主要的复杂度类型,并学习一种名为“圈复杂度”的量化分析方法,帮助我们理解和讨论软件的复杂程度。


概述:什么是软件复杂度? 🤔

我们需要一种方式来讨论和分析软件的复杂程度。沟通非常重要,如果我们没有实际的方法来讨论事物,就很难产生更好的想法。如果有人问你“你的软件复杂吗?”,你该如何回答?在本次课程中,我们将学习如何定义和衡量软件复杂度。

我们将涵盖两个主要主题。第一个是比较和对比两种基本的复杂度概念:本质复杂度和偶然复杂度。第二个主题是关于分析和度量,即“圈复杂度”,它旨在为软件的复杂程度赋予一个数值。


本质复杂度 vs. 偶然复杂度 ⚖️

上一节我们提出了软件复杂度的问题,本节中我们来看看如何划分它。一种我们鼓励你采用的划分方式,是将复杂度分为两大类:本质复杂度偶然复杂度

  • 本质复杂度 是指我们感觉是手头问题固有的任何复杂度。例如,如果你的项目后端有40个路由,你会说这很复杂。但这种复杂是本质的,因为这是工作范围所必需的。
  • 偶然复杂度 是指并非问题固有的复杂度。它是为了解决本不需要引入的复杂度而引入的。例如,自己编写JSON解析器,或者使用非常复杂的方式加密数据。任何使软件变得更难使用、更难理解、更难维护,而实际上并非必要的情况,都属于偶然复杂度。

这种区分在对话中很重要,因为本质复杂度基本上无法消除,但可以通过良好的软件设计来管理。这意味着你无法避免项目需要40个路由,但你可以通过编写和设计代码,使其更优雅、更易于维护(正如我们在第5周关于编写可维护软件的讲座中讨论的)。而偶然复杂度则可以通过更好的工程决策(例如明智地使用库)来直接缓解。

有经验的工程师擅长减少偶然复杂度。虽然很难完全消除,因为人们天生倾向于为所有问题增加偶然复杂度,但这取决于增加的多或少。


如何衡量软件复杂度? 📏

上一节我们介绍了两种复杂度类型,本节中我们来看看如何衡量复杂度。如果我们提出“如何衡量软件复杂度?”这个问题,我们通常可能想衡量的是软件模块之间的耦合度

以下是衡量复杂度时需要考虑的几个方面:

  • 耦合度:衡量不同软件组件之间的紧密程度。我们希望实现低耦合松耦合,这意味着不同的组件应尽可能独立。
  • 内聚度:衡量一个模块内各元素之间的关联程度。我们希望实现高内聚,这意味着功能相似的代码应该放在一起。

简单来说,耦合和内聚讨论的核心是:不同的东西应该分开,相似的东西应该在一起。


圈复杂度:衡量函数分支复杂度 🔄

除了耦合和内聚,我们还有一个新概念:圈复杂度。它是衡量函数分支复杂度的一种方法。

圈复杂度专注于计算通过一个函数的线性独立路径的数量。计算过程很简单:取一个函数,将其转换为控制流图(本质上将其图示化),然后使用公式 V(G) = E - N + 2 进行计算,其中 E 是边的数量,N 是节点的数量。

让我们通过一个例子来理解。假设有以下函数:

if (condition1):
    function_b()
else:
    function_c()

我们可以将其转换为控制流图。从起点开始,程序只能进入 if 分支或 else 分支。进入 if 分支后,总是会执行 function_b();进入 else 分支后,总是会执行 function_c()。最后,两者都到达程序结束点。

在这个图中,边(E)的数量是6,节点(N)的数量是6。应用公式:6 - 6 + 2 = 2。因此,这个函数的圈复杂度是 2圈复杂度数值越低越好,数值越高通常意味着函数越复杂

需要注意的是,绘制控制流图时,只要逻辑正确,具体的画法可以有差异。线性流程中的额外节点和边会在计算中相互抵消,不影响最终结果。


更多圈复杂度示例 📝

以下是更多计算圈复杂度的例子,帮助我们巩固理解:

示例1:包含嵌套条件
对于包含嵌套 if-else 的函数,其控制流图会有更多分支。通过计算边和节点,并应用公式 E - N + 2,我们可以得到更高的圈复杂度值(例如3或4),这反映了更复杂的决策逻辑。

示例2:包含循环
对于包含 while 循环的函数,在控制流图中,循环体本身会形成一个可以返回的路径。计算其边和节点后,同样应用公式即可得到圈复杂度。

示例3:综合案例
对于一个同时包含循环和内部条件判断的函数,其控制流图会展示出多个决策点。仔细追踪所有可能的路径,计算总边数(E)和总节点数(N),就能算出其圈复杂度。

通过这些例子可以看到,圈复杂度提供了一种简单、可量化的方式来理解函数的复杂性。它有助于在团队中设定标准,例如规定“任何函数的圈复杂度不应超过10”,从而促使编写更简单的函数。


圈复杂度的优点与局限 ⚠️

圈复杂度的主要优点是它提供了一种简单、可衡量的方式来理解函数的复杂性。它便于讨论,并且可能有自动化工具来计算它,有助于在组织内强制执行代码简洁性标准。

然而,圈复杂度也有其局限性:

  1. 它假设非分支代码没有复杂度。一个没有 ifelse 或循环的100行长函数,其圈复杂度可能很低,但这并不意味着它一定简单或易于理解。
  2. 有时,将函数过度拆分成更小的部分以降低圈复杂度,反而可能使代码更难阅读。
  3. 它只是衡量复杂度的众多维度之一,需要结合其他因素(如耦合度、内聚度、代码可读性等)综合评估。

总结 🎯

本节课中,我们一起学习了软件复杂度的核心概念。

  • 我们首先将复杂度区分为本质复杂度(问题固有,可管理但难消除)和偶然复杂度(非必要引入,应尽力减少)。
  • 接着,我们回顾了衡量软件结构的两个概念:耦合度(越低越好)和内聚度(越高越好)。
  • 最后,我们深入学习了圈复杂度,这是一种通过分析函数控制流图中的边(E)和节点(N),使用公式 V(G) = E - N + 2 来量化函数分支复杂度的具体方法。我们通过多个示例练习了如何计算它,并讨论了其用途和局限性。

理解这些概念和工具,能帮助我们更好地分析、讨论和改进我们的代码设计,从而构建出更易于理解和维护的软件系统。

026:Git撤销与修改 🧹

在本节课中,我们将要学习如何在Git中撤销操作和修改历史记录。我们将探讨git reset(硬重置与软重置)和git commit --amend(修改提交)等核心命令,了解它们的使用场景和注意事项。


概述

到目前为止,我们在课程中主要关注于在Git历史记录上持续构建。这意味着我们很大程度上将Git历史视为不可变的,即它不会或不能改变。

然而,在使用Git时,有时你会犯错误,或者有时你会想要重写Git历史。这很重要,因为例如你可能需要完全移除某些代码(比如推送了敏感信息),或者发现要修改的内容过于复杂。虽然存在许多需要重写历史的场景,但通常需要非常小心。

重写历史的两种主要方式是:git重置修改Git提交。修改提交相对简单,但我们将首先看看Git重置。


Git硬重置

上一节我们介绍了重写Git历史的概念,本节中我们来看看git reset --hard

git reset --hard 是一种将你的所有代码回退到特定提交的方法。它用于当你想要“回到过去”,并且不关心自那个时间点之后发生的任何事情时。就像把时钟倒拨,让之后的一切仿佛从未发生。

以下是其核心概念:

git reset --hard <commit-hash>

工作原理

  • 当你执行硬重置到某个提交时,Git会将当前分支的指针直接移动到那个提交。
  • 同时,你的工作目录和暂存区的内容也会被完全替换为该提交的快照。
  • 这意味着自目标提交之后的所有更改(包括提交记录和文件内容)在本地都会被丢弃。

示例
假设你克隆了一个仓库,通过git log看到有A、B、C三个提交。如果你执行git reset --hard B,那么:

  • git log 将只显示提交A和B(C消失了)。
  • 你的文件内容会与提交B时完全一致,提交C引入的更改会从你的工作目录中消失。

重要注意事项
硬重置主要在你尚未将更改推送到远程仓库时使用。一旦历史记录已共享(推送到了GitLab等),强行使用git push --force来推送硬重置后的历史会破坏其他协作者的历史记录,必须极其谨慎,并提前与团队沟通。


Git软重置

了解了硬重置会丢弃所有更改后,本节我们来看看更微妙的git reset --soft

git reset --soft 会保持你当前的所有代码不变,但会改变当前分支所指向的提交。

以下是其核心概念:

git reset --soft <commit-hash>

工作原理

  • 软重置只会移动分支指针,不会改变你的工作目录和暂存区。
  • 执行后,自目标提交之后的所有更改都会变成“已修改但未暂存”或“已暂存”的状态(取决于具体差异)。

示例
同样在有A、B、C三个提交的仓库中,执行git reset --soft B

  • git log 将只显示提交A和B。
  • 但你的文件内容仍然包含提交C所做的所有更改。运行git status会看到这些更改等待被提交。

主要用途
软重置在你想压缩多个提交时非常有用。例如,如果你有一系列杂乱的小提交(比如“修复拼写错误”、“再修复一个”、“最终版本”),你可以软重置到第一个小提交之前,然后执行一个新的、包含所有更改的提交,从而清理历史记录,使其更清晰。


修改提交

在学习了如何用软重置整理提交历史后,我们来看一个与之配合使用的强大工具:git commit --amend

git commit --amend 用于修改最近的一次提交。它不会创建一个新的提交,而是会覆盖上一次提交。

以下是其核心概念:

git commit --amend -m “新的提交信息”
# 或者,如果你有新的更改要加入上次提交
git add .
git commit --amend --no-edit # --no-edit 表示不修改提交信息

工作原理

  • 它会将暂存区的更改与上一次提交合并,创建一个新的提交对象来替换旧的。
  • 因此,提交的哈希值会改变,但提交时间戳默认保持不变(这有时会造成“这个文件是两个月前修改的”的错觉,实际上可能是刚修改的)。

典型工作流(结合软重置)

  1. 使用 git reset --soft HEAD~3 将指针回退3个提交,但保留所有更改在暂存区。
  2. 使用 git commit --amend -m “整理后的功能提交” 创建一个新的、整洁的提交,替代之前杂乱的多个提交。
  3. 最后,你可能需要使用 git push --force 来更新远程历史(同样需谨慎)。

总结

本节课中我们一起学习了Git中撤销与修改历史的几种方法:

  • git reset --hard:彻底回退到某个提交点,丢弃之后的所有更改。适用于本地未推送的严重错误修正。
  • git reset --soft:移动分支指针但不改变工作区内容,用于后续重新提交,常用来压缩历史。
  • git commit --amend:修改最近一次提交的内容或信息,是进行小修小补的便捷工具。

核心原则:重写历史是一把双刃剑。在本地、未共享的分支上可以自由使用来整理工作。然而,一旦历史记录已被推送到远程仓库并与他人共享,重写历史(尤其是强制推送)就必须非常小心,因为这会影响所有协作者。在团队协作中,修改已共享的历史前务必进行沟通。

posted @ 2026-03-29 09:32  布客飞龙II  阅读(3)  评论(0)    收藏  举报