hav-cs50-merge-11
哈佛 CS50 中文官方笔记(十二)
第六讲
-
简介
-
用户界面
-
单页应用程序
-
滚动
- 无限滚动
-
动画
-
React
- 加法
简介
-
到目前为止,我们已经讨论了如何使用 HTML 和 CSS 构建简单的网页,以及如何使用 Git 和 GitHub 来跟踪我们代码的变化并与他人协作。我们还熟悉了 Python 编程语言,开始使用 Django 创建 Web 应用程序,并学习了如何使用 Django 模型在我们的网站上存储信息。然后我们介绍了 JavaScript,并学习了如何使用它使网页更加互动。
-
今天,我们将讨论用户界面设计中的常见范式,使用 JavaScript 和 CSS 使我们的网站更加用户友好。
用户界面
用户界面是网页访问者与该页面交互的方式。作为 Web 开发者,我们的目标是让这些交互尽可能愉快,我们可以使用许多方法来实现这一点。
单页应用程序
以前,如果我们想要一个包含多个页面的网站,我们会通过 Django 应用程序中的不同路由来实现这一点。现在,我们有能力只加载一个页面,然后使用 JavaScript 来操作 DOM。这样做的一个主要优点是我们只需要修改实际改变的部分页面。例如,如果我们有一个不根据你的当前页面变化的导航栏(Nav Bar),我们就不想每次切换到页面的新部分时都要重新渲染那个导航栏。
让我们看看一个如何在 JavaScript 中模拟页面切换的例子:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Single Page</title>
<style>
div {
display: none;
}
</style>
<script src="singlepage.js"></script>
</head>
<body>
<button data-page="page1">Page 1</button>
<button data-page="page2">Page 2</button>
<button data-page="page3">Page 3</button>
<div id="page1">
<h1>This is page 1</h1>
</div>
<div id="page2">
<h1>This is page 2</h1>
</div>
<div id="page3">
<h1>This is page 3</h1>
</div>
</body>
</html>
注意在上述 HTML 中,我们有三个按钮和三个 div。目前,div 中只包含一小部分文本,但我们可以想象每个 div 包含我们网站上的一页内容。现在,我们将添加一些 JavaScript,允许我们使用按钮在页面之间切换。
// Shows one page and hides the other two
function showPage(page) {
// Hide all of the divs:
document.querySelectorAll('div').forEach(div => {
div.style.display = 'none';
});
// Show the div provided in the argument
document.querySelector(`#${page}`).style.display = 'block';
}
// Wait for page to loaded:
document.addEventListener('DOMContentLoaded', function() {
// Select all buttons
document.querySelectorAll('button').forEach(button => {
// When a button is clicked, switch to that page
button.onclick = function() {
showPage(this.dataset.page);
}
})
});

在许多情况下,当我们首次访问一个网站时,加载每一页的全部内容将是不高效的,因此我们需要使用服务器来访问新数据。例如,当你访问一个新闻网站时,如果它在你首次访问页面时必须加载所有可用的文章,那么网站加载将花费非常长的时间。我们可以通过使用与我们在前一次讲座中加载货币汇率时使用的类似策略来避免这个问题。这次,我们将探讨如何使用 Django 从我们的单页应用程序发送和接收信息。为了展示这是如何工作的,让我们看看一个简单的 Django 应用程序。它在urls.py中有两个 URL 模式:
urlpatterns = [
path("", views.index, name="index"),
path("sections/<int:num>", views.section, name="section")
]
以及views.py中的两个相应路由。请注意,section路由接受一个整数,然后根据该整数返回一个基于 HTTP 响应的文本字符串。
from django.http import Http404, HttpResponse
from django.shortcuts import render
# Create your views here. def index(request):
return render(request, "singlepage/index.html")
# The texts are much longer in reality, but have
# been shortened here to save space texts = ["Text 1", "Text 2", "Text 3"]
def section(request, num):
if 1 <= num <= 3:
return HttpResponse(texts[num - 1])
else:
raise Http404("No such section")
现在,在我们的index.html文件中,我们将利用我们上次讲座中了解到的 AJAX,向服务器发送请求以获取特定部分的文本并在屏幕上显示:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Single Page</title>
<style>
</style>
<script>
// Shows given section
function showSection(section) {
// Find section text from server
fetch(`/sections/${section}`)
.then(response => response.text())
.then(text => {
// Log text and display on page
console.log(text);
document.querySelector('#content').innerHTML = text;
});
}
document.addEventListener('DOMContentLoaded', function() {
// Add button functionality
document.querySelectorAll('button').forEach(button => {
button.onclick = function() {
showSection(this.dataset.section);
};
});
});
</script>
</head>
<body>
<h1>Hello!</h1>
<button data-section="1">Section 1</button>
<button data-section="2">Section 2</button>
<button data-section="3">Section 3</button>
<div id="content">
</div>
</body>
</html>

现在,我们已经创建了一个网站,我们可以从服务器加载新数据,而无需重新加载整个 HTML 页面!
然而,我们网站的缺点是 URL 现在信息量较少。您会注意到在上面的视频中,即使我们从一个部分切换到另一个部分,URL 仍然保持不变。我们可以使用JavaScript 历史 API来解决这个问题。此 API 允许我们将信息推送到浏览器历史记录并手动更新 URL。让我们看看我们如何使用此 API。想象我们有一个与上一个项目相同的 Django 项目,但这次我们希望修改我们的脚本以使用历史 API:
// When back arrow is clicked, show previous section
window.onpopstate = function(event) {
console.log(event.state.section);
showSection(event.state.section);
}
function showSection(section) {
fetch(`/sections/${section}`)
.then(response => response.text())
.then(text => {
console.log(text);
document.querySelector('#content').innerHTML = text;
});
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('button').forEach(button => {
button.onclick = function() {
const section = this.dataset.section;
// Add the current state to the history
history.pushState({section: section}, "", `section${section}`);
showSection(section);
};
});
});
在上面的showSection函数中,我们使用了history.pushState函数。此函数根据三个参数向我们的浏览历史添加一个新元素:
-
与状态相关的任何数据。
-
大多数网络浏览器忽略的标题参数
-
应该显示在 URL 中的内容
在上面的 JavaScript 中,我们做的另一个更改是在设置onpopstate参数,该参数指定了当用户点击后退箭头时应执行的操作。在这种情况下,我们希望在按钮按下时显示上一个部分。现在,网站看起来更加用户友好:

滚动
为了更新和访问浏览器历史记录,我们使用了名为window的重要 JavaScript 对象。窗口还有一些其他属性,我们可以使用它们来使我们的网站看起来更美观:
-
window.innerWidth:窗口的像素宽度 -
window.innerHeight:窗口的像素高度

当前的窗口代表用户当前可见的内容,而document则指整个网页,通常比窗口大得多,迫使用户滚动上下才能看到页面内容。为了处理滚动,我们可以访问其他变量:
-
window.scrollY:我们从页面顶部滚动的像素数 -
document.body.offsetHeight:整个文档的像素高度。

我们可以使用这些措施来确定用户是否已经滚动到页面的底部,使用比较 window.scrollY + window.innerHeight >= document.body.offsetHeight。例如,以下页面将在我们到达页面底部时将背景颜色更改为绿色:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Scroll</title>
<script>
// Event listener for scrolling
window.onscroll = () => {
// Check if we're at the bottom
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
// Change color to green
document.querySelector('body').style.background = 'green';
} else {
// Change color to white
document.querySelector('body').style.background = 'white';
}
};
</script>
</head>
<body>
<p>1</p>
<p>2</p>
<!-- More paragraphs left out to save space -->
<p>99</p>
<p>100</p>
</body>
</html>

无限滚动
在页面底部更改背景颜色可能并不那么有用,但如果我们想实现 无限滚动,我们可能需要检测我们是否到达了页面的底部。例如,如果你在一个社交媒体网站上,你不想一次性加载所有帖子,你可能想先加载前十个,然后当用户到达底部时再加载下一个十个。让我们看看一个可以实现这一功能的 Django 应用程序。这个应用程序在 urls.py 中有两个路径
urlpatterns = [
path("", views.index, name="index"),
path("posts", views.posts, name="posts")
]
以及 views.py 中的两个相应视图:
import time
from django.http import JsonResponse
from django.shortcuts import render
# Create your views here. def index(request):
return render(request, "posts/index.html")
def posts(request):
# Get start and end points
start = int(request.GET.get("start") or 0)
end = int(request.GET.get("end") or (start + 9))
# Generate list of posts
data = []
for i in range(start, end + 1):
data.append(f"Post #{i}")
# Artificially delay speed of response
time.sleep(1)
# Return list of posts
return JsonResponse({
"posts": data
})
注意,posts 视图需要两个参数:一个 start 点和一个 end 点。在这个视图中,我们创建了自己的 API,可以通过访问网址 localhost:8000/posts?start=10&end=15 来测试,它返回以下 JSON:
{ "posts": [ "Post #10", "Post #11", "Post #12", "Post #13", "Post #14", "Post #15" ] }
现在,在网站加载的 index.html 模板中,我们一开始在主体中只有一个空的 div 和一些样式。注意,我们在开始时加载了静态文件,然后在我们 static 文件夹中引用了一个 JavaScript 文件。
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>My Webpage</title>
<style>
.post {
background-color: #77dd11;
padding: 20px;
margin: 10px;
}
body {
padding-bottom: 50px;
}
</style>
<script scr="{% static 'posts/script.js' %}"></script>
</head>
<body>
<div id="posts">
</div>
</body>
</html>
现在用 JavaScript,我们将等待用户滚动到页面底部,然后使用我们的 API 加载更多帖子:
// Start with first post
let counter = 1;
// Load posts 20 at a time
const quantity = 20;
// When DOM loads, render the first 20 posts
document.addEventListener('DOMContentLoaded', load);
// If scrolled to bottom, load the next 20 posts
window.onscroll = () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
load();
}
};
// Load next set of posts
function load() {
// Set start and end post numbers, and update counter
const start = counter;
const end = start + quantity - 1;
counter = end + 1;
// Get new posts and add posts
fetch(`/posts?start=${start}&end=${end}`)
.then(response => response.json())
.then(data => {
data.posts.forEach(add_post);
})
};
// Add a new post with given contents to DOM
function add_post(contents) {
// Create new post
const post = document.createElement('div');
post.className = 'post';
post.innerHTML = contents;
// Add post to DOM
document.querySelector('#posts').append(post);
};
现在,我们已经创建了一个具有无限滚动的网站!

动画
我们还可以通过添加一些动画来使我们的网站更有趣。事实证明,除了提供样式外,CSS 还使我们能够轻松地动画化 HTML 元素。
要在 CSS 中创建动画,我们使用以下格式,其中动画的具体内容可以包括起始和结束样式(to 和 from)或持续时间不同阶段的样式(从 0% 到 100%)。例如:
@keyframes animation_name {
from {
/* Some styling for the start */
}
to {
/* Some styling for the end */
}
}
或者:
@keyframes animation_name {
0% {
/* Some styling for the start */
}
75% {
/* Some styling after 3/4 of animation */
}
100% {
/* Some styling for the end */
}
}
然后,为了对一个元素应用动画,我们需要包含 animation-name、animation-duration(以秒为单位)和 animation-fill-mode(通常是 forwards)。例如,以下是一个页面,当第一次进入页面时标题会变大:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Animate</title>
<style>
@keyframes grow {
from {
font-size: 20px;
}
to {
font-size: 100px;
}
}
h1 {
animation-name: grow;
animation-duration: 2s;
animation-fill-mode: forwards;
}
</style>
</head>
<body>
<h1>Welcome!</h1>
</body>
</html>

我们不仅可以操纵大小:以下示例展示了我们如何通过更改几行来改变标题的位置:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Animate</title>
<style>
@keyframes move {
from {
left: 0%;
}
to {
left: 50%;
}
}
h1 {
position: relative;
animation-name: move;
animation-duration: 2s;
animation-fill-mode: forwards;
}
</style>
</head>
<body>
<h1>Welcome!</h1>
</body>
</html>

现在,让我们看看设置一些中间 CSS 属性。我们可以在动画的任何百分比处指定样式。在以下示例中,我们将标题从左到右移动,然后通过仅更改上面的动画将其移回左方
@keyframes move {
0% {
left: 0%;
}
50% {
left: 50%;
}
100% {
left: 0%;
}
}

如果我们想要重复动画多次,可以将animation-iteration-count属性更改为大于一的数字(甚至可以设置为infinite以实现无限动画)。我们可以设置许多动画属性,以改变动画的不同方面。
除了 CSS 之外,我们还可以使用 JavaScript 进一步控制动画。让我们使用我们的移动标题示例(具有无限重复)来展示我们如何创建一个开始和停止动画的按钮。假设我们已经有了一个动画、按钮和标题,我们可以添加以下脚本以开始和暂停动画:
document.addEventListener('DOMContentLoaded', function() {
// Find heading
const h1 = document.querySelector('h1');
// Pause Animation by default
h1.style.animationPlayState = 'paused';
// Wait for button to be clicked
document.querySelector('button').onclick = () => {
// If animation is currently paused, begin playing it
if (h1.style.animationPlayState == 'paused') {
h1.style.animationPlayState = 'running';
}
// Otherwise, pause the animation
else {
h1.style.animationPlayState = 'paused';
}
}
})

现在,让我们看看如何将我们对动画的新知识应用到我们之前制作的帖子页面。具体来说,假设我们希望在阅读完帖子后能够隐藏帖子。让我们想象一个与刚刚创建的项目相同的 Django 项目,但有一些 HTML 和 JavaScript 的细微差别。我们将做的第一个更改是修改add_post函数,这次也在帖子的右侧添加了一个按钮:
// Add a new post with given contents to DOM
function add_post(contents) {
// Create new post
const post = document.createElement('div');
post.className = 'post';
post.innerHTML = `${contents} <button class="hide">Hide</button>`;
// Add post to DOM
document.querySelector('#posts').append(post);
};
现在,我们将处理在点击“隐藏”按钮时隐藏帖子。为此,我们将添加一个事件监听器,它在用户点击页面上的任何地方时被触发。然后我们编写一个函数,该函数接受event作为参数,这很有用,因为我们可以使用event.target属性来访问被点击的元素。我们还可以使用parentElement类在 DOM 中找到给定元素的父元素。
// If hide button is clicked, delete the post
document.addEventListener('click', event => {
// Find what was clicked on
const element = event.target;
// Check if the user clicked on a hide button
if (element.className === 'hide') {
element.parentElement.remove()
}
});

我们现在可以看到我们已经实现了隐藏按钮,但它看起来并没有可能那么漂亮。也许我们希望帖子在移除之前先淡出并缩小。为了做到这一点,我们首先创建一个 CSS 动画。下面的动画将花费 75%的时间将opacity从 1 变为 0,这本质上使得帖子缓慢淡出。然后,它将剩余的时间将所有与height相关的属性移动到 0,有效地将帖子缩小到无。
@keyframes hide {
0% {
opacity: 1;
height: 100%;
line-height: 100%;
padding: 20px;
margin-bottom: 10px;
}
75% {
opacity: 0;
height: 100%;
line-height: 100%;
padding: 20px;
margin-bottom: 10px;
}
100% {
opacity: 0;
height: 0px;
line-height: 0px;
padding: 0px;
margin-bottom: 0px;
}
}
接下来,我们将添加此动画到我们帖子的 CSS 中。注意,我们最初将animation-play-state设置为paused,这意味着帖子默认不会隐藏。
.post {
background-color: #77dd11;
padding: 20px;
margin-bottom: 10px;
animation-name: hide;
animation-duration: 2s;
animation-fill-mode: forwards;
animation-play-state: paused;
}
最后,我们希望在点击“隐藏”按钮后开始动画,然后移除帖子。我们可以通过编辑上面的 JavaScript 来实现这一点:
// If hide button is clicked, delete the post
document.addEventListener('click', event => {
// Find what was clicked on
const element = event.target;
// Check if the user clicked on a hide button
if (element.className === 'hide') {
element.parentElement.style.animationPlayState = 'running';
element.parentElement.addEventListener('animationend', () => {
element.parentElement.remove();
});
}
});

如您所见,隐藏功能现在看起来好多了!
React
到目前为止,你可以想象在一个更复杂的网站上需要多少 JavaScript 代码。我们可以通过使用 JavaScript 框架来减轻我们实际上需要编写的代码量,就像我们使用 Bootstrap 作为 CSS 框架来减少我们实际上需要编写的 CSS 量一样。最受欢迎的 JavaScript 框架之一是一个名为React的库。
到目前为止,在这个课程中,我们一直在使用命令式编程方法,其中我们给计算机一组要执行的语句。例如,为了更新 HTML 页面中的计数器,我们可能有一段看起来像这样的代码:
查看视图:
<h1>0</h1>
逻辑:
let num = parseInt(document.querySelector("h1").innerHTML);
num += 1;
document.querySelector("h1").innerHTML = num;
React 允许我们使用声明式编程,这将使我们能够简单地编写代码来解释我们希望显示的内容,而不用担心如何显示它。在 React 中,计数器可能看起来更像是这样:
查看视图:
<h1>{num}</h1>
逻辑:
num += 1;
React 框架围绕组件的概念构建,每个组件都可以有一个底层状态。组件可以是网页上可见的任何东西,比如帖子或导航栏,而状态是与该组件相关的一组变量。React 的美丽之处在于,当状态发生变化时,React 会自动相应地更改 DOM。
有许多种使用 React 的方法(包括 Facebook 发布的流行create-react-app命令),但今天我们将专注于直接在 HTML 文件中开始。为此,我们必须导入三个 JavaScript 包:
-
React:定义组件及其行为 -
ReactDOM:将 React 组件插入到 DOM 中 -
Babel:将JSX,我们在 React 中使用的语言,转换为浏览器可以解释的纯 JavaScript。JSX 与 JavaScript 非常相似,但有一些额外的功能,包括在代码中表示 HTML 的能力。
让我们深入其中,创建我们的第一个 React 应用!
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://unpkg.com/react@17/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<title>Hello</title>
</head>
<body>
<div id="app"></div>
<script type="text/babel">
function App() {
return (
<div>
Hello!
</div>
);
}
ReactDOM.render(<App />, document.querySelector("#app"));
</script>
</body>
</html>
由于这是我们第一个 React 应用,让我们详细看看这段代码的每个部分都在做什么:
-
在标题上方三行中,我们导入 React、ReactDOM 和 Babel 的最新版本。
-
在主体中,我们包含一个具有
id为app的单个div。我们几乎总是想留空,并在下面的 React 代码中填充。 -
我们包含一个脚本标签,指定
type="text/babel"。这向浏览器发出信号,表示以下脚本需要使用 Babel 进行翻译。 -
接下来,我们创建一个名为
App的组件。React 中的组件可以用 JavaScript 函数表示。 -
我们的组件返回我们想要渲染到 DOM 中的内容。在这种情况下,我们简单地返回
<div>Hello!</div>。 -
我们脚本中的最后一行使用了
ReactDOM.render函数,它接受两个参数:-
一个要渲染的组件
-
DOM 中的一个元素,其中应该渲染组件
-
现在我们已经理解了代码的作用,我们可以看看生成的网页:

React 的一个有用特性是能够在其他组件内渲染组件。为了演示这一点,让我们创建另一个名为Hello的组件:
function Hello(props) {
return (
<h1>Hello</h1>
);
}
现在,让我们在App组件内部渲染三个Hello组件:
function App() {
return (
<div>
<Hello />
<Hello />
<Hello />
</div>
);
}
这给我们一个看起来像这样的页面:

到目前为止,组件并没有那么有趣,因为它们都是完全相同的。我们可以通过为它们添加额外的属性(在 React 术语中称为props)来使这些组件更加灵活。例如,假设我们希望向三个人打招呼。我们可以在一个类似于 HTML 属性的方法中提供这些人的名字:
function App() {
return (
<div>
<Hello name="Harry" />
<Hello name="Ron" />
<Hello name="Hermione" />
</div>
);
}
我们可以使用props.PROP_NAME来访问这些 props。然后我们可以使用花括号将其插入到我们的 JSX 中:
function Hello(props) {
return (
<h1>Hello, {props.name}!</h1>
);
}
现在,我们的页面显示了三个名字!

现在,让我们看看我们如何使用 React 重新实现我们在首次使用 JavaScript 时构建的计数器页面。我们的整体结构将保持不变,但在我们的App组件内部,我们将使用 React 的useState钩子为我们的组件添加状态。useState的参数是状态的初始值,我们将将其设置为0。该函数返回表示状态的变量和一个允许我们更新状态的函数。
const [count, setCount] = React.useState(0);
现在,我们可以工作于函数将渲染的内容,我们将指定一个标题和一个按钮。我们还将添加一个事件监听器,当按钮被点击时,React 使用onClick属性来处理:
return (
<div>
<h1>{count}</h1>
<button onClick={updateCount}>Count</button>
</div>
);
最后,让我们定义updateCount函数。为此,我们将使用setCount函数,它可以接受作为状态的新值作为参数。
function updateCount() {
setCount(count + 1);
}
现在我们有一个功能齐全的计数器网站!

加法
现在我们已经对 React 框架有了感觉,让我们利用所学知识来构建一个类似游戏的网站,用户将在网站上解决加法问题。我们将首先创建一个与我们的其他 React 页面设置相同的文件。为了开始构建这个应用程序,让我们思考我们可能想要在状态中跟踪的内容。我们应该包括任何我们认为用户在我们页面上可能会改变的内容。我们的状态可能包括:
-
num1:要相加的第一个数字 -
num2:要相加的第二个数字 -
response:用户输入的内容 -
score:用户回答正确的题目数量。
现在,我们的状态可以是一个包含所有这些信息的 JavaScript 对象:
const [state, setState] = React.useState({
num1: 1,
num2: 1,
response: "",
score: 0
});
现在,使用状态中的值,我们可以渲染一个基本的用户界面。
return (
<div>
<div>{state.num1} + {state.num2}</div>
<input value={state.response} />
<div>Score: {state.score}</div>
</div>
);
现在,网站的基本布局看起来像这样:

在这个阶段,用户无法在输入框中输入任何内容,因为它的值被固定为state.response,当前是空字符串。为了解决这个问题,让我们给输入元素添加一个onChange属性,并将其设置为名为updateResponse的函数。
onChange={updateResponse}
现在,我们必须定义updateResposne函数,它接受触发函数的事件,并将response设置为输入的当前值。这个函数允许用户输入,并将输入的内容存储在state中。
function updateResponse(event) {
setState({
...state,
response: event.target.value
});
}
现在,让我们添加用户提交问题的功能。我们首先添加另一个事件监听器,并将其链接到我们将要编写的函数:
onKeyPress={inputKeyPress}
现在,我们将定义inputKeyPress函数。在这个函数中,我们首先检查是否按下了Enter键,然后检查答案是否正确。当用户回答正确时,我们希望增加 1 分,为下一个问题选择随机数字,并清除响应。如果答案不正确,我们希望减少 1 分并清除响应。
function inputKeyPress(event) {
if (event.key === "Enter") {
const answer = parseInt(state.response);
if (answer === state.num1 + state.num2) {
// User got question right
setState({
...state,
score: state.score + 1,
response: "",
num1: Math.ceil(Math.random() * 10),
num2: Math.ceil(Math.random() * 10)
});
} else {
// User got question wrong
setState({
...state,
score: state.score - 1,
response: ""
})
}
}
}
为了给应用程序添加一些收尾工作,让我们给页面添加一些样式。我们将使应用中的所有内容居中,然后通过给包含问题的 div 添加id为problem,并添加以下 CSS 到样式标签来使问题更大:
#app {
text-align: center;
font-family: sans-serif;
}
#problem {
font-size: 72px;
}
最后,让我们添加在获得 10 分后赢得游戏的能力。为此,我们将在render函数中添加一个条件,一旦我们获得 10 分,就返回完全不同的内容:
if (state.score === 10) {
return (
<div id="winner">You won!</div>
);
}
为了使胜利更加激动人心,我们还将给替代 div 添加一些样式:
#winner {
font-size: 72px;
color: green;
}
现在,让我们看看我们的应用程序!

今天的内容就到这里!下次,我们将讨论构建大型 Web 应用程序的一些最佳实践。
第七讲
-
简介
-
测试
-
断言
- 测试驱动开发
-
单元测试
-
Django 测试
- 客户端测试
-
Selenium
-
持续集成/持续部署
-
GitHub Actions
-
Docker
简介
-
到目前为止,我们讨论了如何使用 HTML 和 CSS 构建简单的网页,以及如何使用 Git 和 GitHub 来跟踪我们代码的变化并与他人协作。我们还熟悉了 Python 编程语言,开始使用 Django 创建 Web 应用程序,并学习了如何使用 Django 模型在我们的网站上存储信息。然后我们介绍了 JavaScript,并学习了如何使用它使网页更加互动,还讨论了使用动画和 React 来进一步改进我们的用户界面。
-
今天,我们将学习关于在处理和发布大型项目时的最佳实践。
测试
软件开发过程中的一个重要部分是测试我们所编写的代码,以确保一切按预期运行。在本讲座中,我们将讨论几种我们可以改进测试代码的方法。
断言
我们可以在 Python 中运行测试的最简单方法之一是使用assert命令。此命令后面跟一个应该为True的表达式。如果表达式为True,则不会发生任何事情,如果为False,则会抛出异常。让我们看看我们如何将命令集成到测试我们在学习 Python 时编写的square函数中。当函数编写正确时,由于assert为True,所以不会发生任何事情
def square(x):
return x * x
assert square(10) == 100
""" Output: """
如果编写错误,则会抛出异常。
def square(x):
return x + x
assert square(10) == 100
""" Output:
Traceback (most recent call last):
File "assert.py", line 4, in <module>
assert square(10) == 100
AssertionError """
测试驱动开发
当你开始构建更大的项目时,你可能想要考虑使用测试驱动开发,这是一种开发风格,每次修复一个错误时,你都会添加一个测试来检查该错误,并将其添加到一个不断增长的测试集中,每次你进行更改时都会运行这些测试。这将帮助你确保你添加到项目中的新功能不会干扰现有的功能。
现在,让我们看看一个稍微复杂一些的函数,并思考编写测试如何帮助我们找到错误。我们现在将编写一个名为is_prime的函数,该函数在其输入是质数时返回True:
import math
def is_prime(n):
# We know numbers less than 2 are not prime
if n < 2:
return False
# Checking factors up to sqrt(n)
for i in range(2, int(math.sqrt(n))):
# If i is a factor, return false
if n % i == 0:
return False
# If no factors were found, return true
return True
现在,让我们看看我们编写的测试prime函数的函数:
from prime import is_prime
def test_prime(n, expected):
if is_prime(n) != expected:
print(f"ERROR on is_prime({n}), expected {expected}")
到目前为止,我们可以进入我们的 Python 解释器并测试一些值:
>>> test_prime(5, True)
>>> test_prime(10, False)
>>> test_prime(25, False)
ERROR on is_prime(25), expected False
从上面的输出中我们可以看到,5 和 10 被正确地识别为质数和非质数,但 25 被错误地识别为质数,所以我们的函数肯定有问题。在我们查看函数的问题之前,让我们看看一种自动化测试的方法。我们可以这样做的一种方法是通过创建一个 shell 脚本,或者可以在我们的终端中运行的脚本。这些文件需要 .sh 扩展名,所以我们的文件将被称为 tests0.sh。下面每一行都包含
-
使用
python3指定我们正在运行的 Python 版本 -
-c表示我们希望运行一个命令 -
一个以字符串格式运行的命令
python3 -c "from tests0 import test_prime; test_prime(1, False)"
python3 -c "from tests0 import test_prime; test_prime(2, True)"
python3 -c "from tests0 import test_prime; test_prime(8, False)"
python3 -c "from tests0 import test_prime; test_prime(11, True)"
python3 -c "from tests0 import test_prime; test_prime(25, False)"
python3 -c "from tests0 import test_prime; test_prime(28, False)"
现在,我们可以在终端中运行这些命令,通过运行 ./tests0.sh,得到这个结果:
ERROR on is_prime(8), expected False
ERROR on is_prime(25), expected False
单元测试
尽管我们能够使用上述方法自动运行测试,但我们仍然可能希望避免逐个编写这些测试。幸运的是,我们可以使用 Python 的 unittest 库使这个过程变得容易一些。让我们看看我们的 is_prime 函数的测试程序可能是什么样子。
# Import the unittest library and our function import unittest
from prime import is_prime
# A class containing all of our tests class Tests(unittest.TestCase):
def test_1(self):
"""Check that 1 is not prime."""
self.assertFalse(is_prime(1))
def test_2(self):
"""Check that 2 is prime."""
self.assertTrue(is_prime(2))
def test_8(self):
"""Check that 8 is not prime."""
self.assertFalse(is_prime(8))
def test_11(self):
"""Check that 11 is prime."""
self.assertTrue(is_prime(11))
def test_25(self):
"""Check that 25 is not prime."""
self.assertFalse(is_prime(25))
def test_28(self):
"""Check that 28 is not prime."""
self.assertFalse(is_prime(28))
# Run each of the testing functions if __name__ == "__main__":
unittest.main()
注意到我们 Tests 类中的每个函数都遵循了一个模式:
-
函数的名称以
test_开头。这对于函数在调用unittest.main()时自动运行是必要的。 -
每个测试都接受
self参数。这是在 Python 类中编写方法时的标准做法。 -
每个函数的第一行包含一个由三个引号包围的 文档字符串。这些不仅是为了代码的可读性。当测试运行时,如果测试失败,注释将作为测试的描述显示。
-
每个函数的下一行包含了一个形式为
self.assertSOMETHING的断言。你可以做出很多不同的断言,包括assertTrue、assertFalse、assertEqual和assertGreater。你可以通过查看 文档 来找到这些以及其他断言。
现在,让我们检查这些测试的结果:
...F.F
======================================================================
FAIL: test_25 (__main__.Tests)
Check that 25 is not prime.
----------------------------------------------------------------------
Traceback (most recent call last):
File "tests1.py", line 26, in test_25
self.assertFalse(is_prime(25))
AssertionError: True is not false
======================================================================
FAIL: test_8 (__main__.Tests)
Check that 8 is not prime.
----------------------------------------------------------------------
Traceback (most recent call last):
File "tests1.py", line 18, in test_8
self.assertFalse(is_prime(8))
AssertionError: True is not false
----------------------------------------------------------------------
Ran 6 tests in 0.001s
FAILED (failures=2)
运行测试后,unittest 会提供一些关于它发现的有用信息。在第一行,它按测试编写的顺序给出了成功的一系列 . 和失败的一系列 F。
...F.F
接下来,对于每个失败的测试,我们还会得到失败函数的名称:
FAIL: test_25 (__main__.Tests)
我们之前提供的描述性注释:
Check that 25 is not prime.
以及异常的跟踪信息:
Traceback (most recent call last):
File "tests1.py", line 26, in test_25
self.assertFalse(is_prime(25))
AssertionError: True is not false
最后,我们得到了一个关于运行了多少个测试、花费了多少时间以及有多少失败的概述:
Ran 6 tests in 0.001s
FAILED (failures=2)
现在,让我们看看如何修复我们函数中的错误。结果是,我们需要在我们的 for 循环中测试一个额外的数字。例如,当 n 是 25 时,平方根是 5,但当它是 range 函数的一个参数时,for 循环在数字 4 处终止。因此,我们可以简单地改变 for 循环的头部为:
for i in range(2, int(math.sqrt(n)) + 1):
现在,当我们再次使用我们的单元测试运行测试时,我们得到以下输出,表明我们的更改修复了错误。
......
----------------------------------------------------------------------
Ran 6 tests in 0.000s
OK
随着你对这个函数进行优化,这些自动化测试将变得更加有用。例如,你可能想利用这样一个事实,即你不需要检查所有整数作为因子,只需检查较小的质数(如果一个数不能被 3 整除,它也不能被 6、9、12 等整除),或者你可能想使用更高级的概率性质数测试,如Fermat和Miller-Rabin质数测试。无论何时你修改此函数以改进它,你都需要能够轻松地再次运行你的单元测试,以确保你的函数仍然正确。
Django 测试
现在,让我们看看在创建 Django 应用程序时如何应用自动化测试的理念。在处理这个项目时,我们将使用我们在第一次学习 Django 模型时创建的flights项目。我们首先将向我们的Flight模型添加一个方法,该方法通过检查两个条件来验证航班是否有效:
-
起点与目的地不同
-
持续时间大于 0 分钟
现在,我们的模型可能看起来像这样:
class Flight(models.Model):
origin = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="departures")
destination = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="arrivals")
duration = models.IntegerField()
def __str__(self):
return f"{self.id}: {self.origin} to {self.destination}"
def is_valid_flight(self):
return self.origin != self.destination or self.duration > 0
为了确保我们的应用程序按预期工作,每次我们创建一个新的应用程序时,我们都会自动获得一个tests.py文件。当我们第一次打开这个文件时,我们看到 Django 的TestCase库被自动导入:
from django.test import TestCase
使用TestCase库的一个优点是,当我们运行测试时,将仅用于测试目的创建一个全新的数据库。这很有帮助,因为我们避免了意外修改或删除数据库中现有条目的风险,我们也不必担心移除仅用于测试而创建的虚拟条目。
要开始使用这个库,我们首先想要导入我们所有的模型:
from .models import Flight, Airport, Passenger
然后我们将创建一个新的类,该类扩展了我们刚刚导入的TestCase类。在这个类中,我们将定义一个setUp函数,该函数将在测试过程开始时运行。在这个函数中,我们可能想要创建。我们的类将如下所示:
class FlightTestCase(TestCase):
def setUp(self):
# Create airports.
a1 = Airport.objects.create(code="AAA", city="City A")
a2 = Airport.objects.create(code="BBB", city="City B")
# Create flights.
Flight.objects.create(origin=a1, destination=a2, duration=100)
Flight.objects.create(origin=a1, destination=a1, duration=200)
Flight.objects.create(origin=a1, destination=a2, duration=-100)
现在我们测试数据库中有了一些条目,让我们向这个类添加一些函数来执行一些测试。首先,让我们确保我们的departures和arrivals字段工作正常,通过尝试计算从机场AAA出发的航班数量(我们知道应该是 3)和到达数量(应该是 1):
def test_departures_count(self):
a = Airport.objects.get(code="AAA")
self.assertEqual(a.departures.count(), 3)
def test_arrivals_count(self):
a = Airport.objects.get(code="AAA")
self.assertEqual(a.arrivals.count(), 1)
我们还可以测试我们添加到Flight模型中的is_valid_flight函数。我们将首先断言当航班有效时,该函数确实返回 true:
def test_valid_flight(self):
a1 = Airport.objects.get(code="AAA")
a2 = Airport.objects.get(code="BBB")
f = Flight.objects.get(origin=a1, destination=a2, duration=100)
self.assertTrue(f.is_valid_flight())
接下来,让我们确保具有无效目的地和持续时间的航班返回 false:
def test_invalid_flight_destination(self):
a1 = Airport.objects.get(code="AAA")
f = Flight.objects.get(origin=a1, destination=a1)
self.assertFalse(f.is_valid_flight())
def test_invalid_flight_duration(self):
a1 = Airport.objects.get(code="AAA")
a2 = Airport.objects.get(code="BBB")
f = Flight.objects.get(origin=a1, destination=a2, duration=-100)
self.assertFalse(f.is_valid_flight())
现在,为了运行我们的测试,我们将运行python manage.py test。这个输出的结果几乎与使用 Python unittest库时的输出相同,尽管它也记录了它正在创建和销毁测试数据库:
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..FF.
======================================================================
FAIL: test_invalid_flight_destination (flights.tests.FlightTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/cleggett/Documents/cs50/web_notes_files/7/django/airline/flights/tests.py", line 37, in test_invalid_flight_destination
self.assertFalse(f.is_valid_flight())
AssertionError: True is not false
======================================================================
FAIL: test_invalid_flight_duration (flights.tests.FlightTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/cleggett/Documents/cs50/web_notes_files/7/django/airline/flights/tests.py", line 43, in test_invalid_flight_duration
self.assertFalse(f.is_valid_flight())
AssertionError: True is not false
----------------------------------------------------------------------
Ran 5 tests in 0.018s
FAILED (failures=2)
Destroying test database for alias 'default'...
从上面的输出中我们可以看到,有时is_valid_flight在应该返回False的时候返回了True。进一步检查我们的函数后,我们发现错误在于使用了or而不是and,这意味着只有当飞行要求中的一项被满足时,航班才被认为是有效的。如果我们把函数改为这样:
def is_valid_flight(self):
return self.origin != self.destination and self.duration > 0
我们可以再次运行测试,并得到更好的结果:
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.014s
OK
Destroying test database for alias 'default'...
Client Testing
在创建 Web 应用程序时,我们可能不仅想要检查特定函数是否工作,还想要检查单个 Web 页面是否按预期加载。我们可以通过在我们的 Django 测试类中创建一个Client对象,然后使用该对象进行请求来实现这一点。为了做到这一点,我们首先必须将Client添加到我们的导入中:
from django.test import Client, TestCase
例如,现在让我们添加一个测试来确保我们得到 HTTP 响应代码 200,并且我们的三个航班都被添加到响应的上下文中:
def test_index(self):
# Set up client to make requests
c = Client()
# Send get request to index page and store response
response = c.get("/flights/")
# Make sure status code is 200
self.assertEqual(response.status_code, 200)
# Make sure three flights are returned in the context
self.assertEqual(response.context["flights"].count(), 3)
我们可以类似地检查以确保我们得到有效页面的有效响应代码,以及不存在页面的无效响应代码。(注意,我们使用Max函数来找到最大的id,我们通过在文件顶部包含from django.db.models import Max来访问它)
def test_valid_flight_page(self):
a1 = Airport.objects.get(code="AAA")
f = Flight.objects.get(origin=a1, destination=a1)
c = Client()
response = c.get(f"/flights/{f.id}")
self.assertEqual(response.status_code, 200)
def test_invalid_flight_page(self):
max_id = Flight.objects.all().aggregate(Max("id"))["id__max"]
c = Client()
response = c.get(f"/flights/{max_id + 1}")
self.assertEqual(response.status_code, 404)
最后,让我们添加一些测试以确保乘客和非乘客列表被按预期生成:
def test_flight_page_passengers(self):
f = Flight.objects.get(pk=1)
p = Passenger.objects.create(first="Alice", last="Adams")
f.passengers.add(p)
c = Client()
response = c.get(f"/flights/{f.id}")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["passengers"].count(), 1)
def test_flight_page_non_passengers(self):
f = Flight.objects.get(pk=1)
p = Passenger.objects.create(first="Alice", last="Adams")
c = Client()
response = c.get(f"/flights/{f.id}")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["non_passengers"].count(), 1)
现在,我们可以一起运行所有的测试,看到目前我们没有错误!
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..........
----------------------------------------------------------------------
Ran 10 tests in 0.048s
OK
Destroying test database for alias 'default'...
Selenium
到目前为止,我们已经能够使用 Python 和 Django 测试我们编写的服务器端代码,但随着我们构建应用程序,我们还将想要为我们的客户端代码创建测试。例如,让我们回顾一下我们的counter.html页面,并为其编写一些测试。
我们将开始编写一个稍微不同的计数页面,其中包含一个用于减少计数的按钮:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Counter</title>
<script>
// Wait for page to load
document.addEventListener('DOMContentLoaded', () => {
// Initialize variable to 0
let counter = 0;
// If increase button clicked, increase counter and change inner html
document.querySelector('#increase').onclick = () => {
counter ++;
document.querySelector('h1').innerHTML = counter;
}
// If decrease button clicked, decrease counter and change inner html
document.querySelector('#decrease').onclick = () => {
counter --;
document.querySelector('h1').innerHTML = counter;
}
})
</script>
</head>
<body>
<h1>0</h1>
<button id="increase">+</button>
<button id="decrease">-</button>
</body>
</html>
现在如果我们想测试这段代码,我们只需打开我们的网页浏览器,点击两个按钮,观察发生了什么。然而,随着你编写越来越大的单页应用程序,这会变得非常繁琐,这就是为什么有几个框架被创建出来以帮助进行浏览器内测试,其中一个叫做Selenium。
使用 Selenium,我们可以在 Python 中定义一个测试文件,在那里我们可以模拟用户打开一个网络浏览器,导航到我们的页面,并与它交互。我们在做这件事时使用的主要工具被称为Web Driver,它将在您的计算机上打开一个网络浏览器。让我们看看我们如何开始使用这个库来与页面进行交互。注意,以下我们使用了selenium和ChromeDriver。Selenium 可以通过运行pip install selenium来为 Python 安装,而ChromeDriver可以通过运行pip install chromedriver-py来安装
import os
import pathlib
import unittest
from selenium import webdriver
# Finds the Uniform Resourse Identifier of a file def file_uri(filename):
return pathlib.Path(os.path.abspath(filename)).as_uri()
# Sets up web driver using Google chrome driver = webdriver.Chrome()
上述代码是我们需要的所有基本设置,因此现在我们可以通过使用 Python 解释器来探索一些更有趣的用途。关于前几行的一个注意事项是,为了针对特定的页面,我们需要该页面的统一资源标识符(URI),这是一个唯一的字符串,代表该资源。
# Find the URI of our newly created file >>> uri = file_uri("counter.html")
# Use the URI to open the web page >>> driver.get(uri)
# Access the title of the current page >>> driver.title
'Counter'
# Access the source code of the page >>> driver.page_source
'<html lang="en"><head>\n <title>Counter</title>\n <script>\n \n // Wait for page to load\n document.addEventListener(\'DOMContentLoaded\', () => {\n\n // Initialize variable to 0\n let counter = 0;\n\n // If increase button clicked, increase counter and change inner html\n document.querySelector(\'#increase\').onclick = () => {\n counter ++;\n document.querySelector(\'h1\').innerHTML = counter;\n }\n\n // If decrease button clicked, decrease counter and change inner html\n document.querySelector(\'#decrease\').onclick = () => {\n counter --;\n document.querySelector(\'h1\').innerHTML = counter;\n }\n })\n </script>\n </head>\n <body>\n <h1>0</h1>\n <button id="increase">+</button>\n <button id="decrease">-</button>\n \n</body></html>'
# Find and store the increase and decrease buttons: >>> increase = driver.find_element_by_id("increase")
>>> decrease = driver.find_element_by_id("decrease")
# Simulate the user clicking on the two buttons >>> increase.click()
>>> increase.click()
>>> decrease.click()
# We can even include clicks within other Python constructs: >>> for i in range(25):
... increase.click()
现在,让我们看看我们如何使用这个模拟来创建我们页面的自动化测试:
# Standard outline of testing class class WebpageTests(unittest.TestCase):
def test_title(self):
"""Make sure title is correct"""
driver.get(file_uri("counter.html"))
self.assertEqual(driver.title, "Counter")
def test_increase(self):
"""Make sure header updated to 1 after 1 click of increase button"""
driver.get(file_uri("counter.html"))
increase = driver.find_element_by_id("increase")
increase.click()
self.assertEqual(driver.find_element_by_tag_name("h1").text, "1")
def test_decrease(self):
"""Make sure header updated to -1 after 1 click of decrease button"""
driver.get(file_uri("counter.html"))
decrease = driver.find_element_by_id("decrease")
decrease.click()
self.assertEqual(driver.find_element_by_tag_name("h1").text, "-1")
def test_multiple_increase(self):
"""Make sure header updated to 3 after 3 clicks of increase button"""
driver.get(file_uri("counter.html"))
increase = driver.find_element_by_id("increase")
for i in range(3):
increase.click()
self.assertEqual(driver.find_element_by_tag_name("h1").text, "3")
if __name__ == "__main__":
unittest.main()
现在,如果我们运行 python tests.py,我们的模拟将在浏览器中执行,然后测试结果将被打印到控制台。以下是一个示例,当代码中存在错误且测试失败时,它可能看起来是这样的:

CI/CD
CI/CD,代表持续集成和持续交付,是一套软件开发最佳实践,它规定了由一组人员编写的代码,以及该代码如何随后交付给应用程序的用户。正如其名所示,这种方法由两个主要部分组成:
-
持续集成:
-
主分支上的频繁合并
-
每次合并时进行自动单元测试
-
-
持续交付:
- 短发布周期,意味着应用程序的新版本会频繁发布。
CI/CD 由于以下原因在软件开发团队中越来越受欢迎:
-
当不同的团队成员正在开发不同的功能时,当多个功能同时结合时,可能会出现许多兼容性问题。持续集成允许团队在出现冲突时解决小问题。
-
由于单元测试是在每次合并时运行的,因此当测试失败时,更容易隔离导致问题的代码部分。
-
经常发布新版本的应用程序允许开发者在发布后隔离可能出现的问题。
-
逐步发布小而渐进的变化,使用户能够逐渐适应新的应用程序功能,而不是被一个全新的版本所淹没
-
不等待发布新功能使公司能够在竞争激烈的市场中保持领先。
GitHub Actions
一个用于帮助持续集成的流行工具被称为 GitHub Actions。GitHub Actions 允许我们创建工作流程,其中我们可以指定每次有人向 git 仓库推送时需要执行的操作。例如,我们可能希望在每次推送时检查是否遵循了样式指南,或者是否通过了一组单元测试。
为了设置 GitHub Action,我们将使用一种名为 YAML 的配置语言。YAML 将其数据结构化为键值对(类似于 JSON 对象或 Python 字典)。以下是一个简单的 YAML 文件示例:
key1: value1
key2: value2
key3:
- item1
- item2
- item3
现在,让我们看看一个配置 YAML 文件(其形式为 name.yml 或 name.yaml)的例子,这个文件与 GitHub Actions 一起工作。为此,我将在我的仓库中创建一个 .github 目录,然后在其中创建一个 workflows 目录,最后在这个目录中创建一个 ci.yml 文件。在这个文件中,我们将编写:
name: Testing
on: push
jobs:
test_project:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run Django unit tests
run: |
pip3 install --user django
python3 manage.py test
由于这是我们第一次使用 GitHub Actions,让我们看看这个文件的每个部分都在做什么:
-
首先,我们给工作流程一个
name,在我们的例子中是 Testing。 -
接下来,使用
on键,我们指定工作流程应该在何时运行。在我们的情况下,我们希望在有人向仓库推送时执行测试。 -
文件的其余部分包含在
jobs键中,它指示每次推送时应运行哪些工作。-
在我们的情况下,唯一的工作是
test_project。每个工作都必须定义两个组件-
runs-on键指定我们希望我们的代码在 GitHub 的哪个虚拟机上运行。 -
steps键提供了在运行此工作时应发生的操作-
在
uses键中,我们指定我们希望使用哪个 GitHub Action。actions/checkout@v2是 GitHub 编写的我们可以使用的操作。 -
在这里,
name键允许我们提供对所采取操作的描述 -
在
run键之后,我们输入希望在 GitHub 服务器上运行的命令。在我们的例子中,我们希望安装 Django 然后运行测试文件。
-
-
-
现在,让我们在 GitHub 中打开我们的仓库,并查看页面顶部附近的一些标签页:
-
代码:这是我们使用频率最高的标签页,因为它允许我们查看目录中的文件和文件夹。
-
问题:在这里,我们可以打开和关闭问题,这些问题是请求修复错误或新功能。我们可以将其视为我们应用程序的待办事项列表。
-
拉取请求:希望将某个分支的代码合并到另一个分支的人的请求。这是一个有用的工具,因为它允许人们在代码集成到主分支之前进行 代码审查,并发表评论和提供建议。
-
GitHub Actions:这是我们进行持续集成时使用的标签页,因为它提供了每次推送后发生的操作日志。
这里,让我们假设我们在修复 models.py 文件中 airport 项目内的 is_valid_flight 函数中的错误之前,已经推送了我们的更改。我们现在可以导航到 GitHub Actions 选项卡,点击我们最近的推送,点击失败的操作,并查看日志:

现在,在修复了错误之后,我们可以再次尝试并找到更好的结果:

Docker
在软件开发的世界中,当你的电脑配置与应用程序运行时的配置不同时,可能会出现问题。你可能有一个不同的 Python 版本或安装了一些额外的包,这些包允许应用程序在你的电脑上顺利运行,而它在服务器上可能会崩溃。为了避免这些问题,我们需要确保所有参与项目的人都使用相同的环境。一种方法是通过使用名为 Docker 的工具来实现,这是一个容器化软件,意味着它可以在你的电脑中创建一个隔离的环境,可以在许多协作者和运行你网站的服务器之间标准化。虽然 Docker 有点像 虚拟机,但它们实际上是不同的技术。虚拟机(如 GitHub Actions 或当你启动 AWS 服务器时使用的)实际上是一个完整的虚拟计算机,具有自己的操作系统,这意味着它在任何运行的地方都会占用很多空间。另一方面,Docker 通过在现有计算机中设置容器来工作,因此占用的空间更少。
现在我们已经了解了 Docker 容器的概念,让我们看看如何在我们的电脑上配置一个。我们做这件事的第一步将是创建一个名为 Dockerfile 的 Docker 文件。在这个文件中,我们将提供创建 Docker 镜像 的指令,该镜像描述了我们希望在容器中包含的库和二进制文件。以下是我们 Dockerfile 可能的样子示例:
FROM python:3
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN pip install -r requirements.txt
CMD ["python3", "manage.py", "runserver", "0.0.0.0:8000"]
这里,我们将深入探讨上述文件实际上做了什么:
-
FROM python3: 这表明我们是以安装了 Python 3 的标准镜像为基础构建这个镜像。这在编写 Docker 文件时相当常见,因为它允许你避免在每个新镜像中重新定义相同的基本设置。 -
COPY . /usr/src/app: 这表明我们希望将当前目录(.)中的所有内容复制到新容器中的/usr/src/app目录。 -
WORKDIR /usr/src/app: 这设置了我们在容器内运行命令的位置。(有点像终端上的cd命令) -
RUN pip install -r requirements.txt: 在这一行中,假设你已经将所有需求包含在一个名为requirements.txt的文件中,它们都将被安装到容器内。 -
CMD ["python3", "manage.py", "runserver", "0.0.0.0:8000"]:最后,我们指定了启动容器时应运行的命令。
到目前为止,在这个课程中,我们只使用了 SQLite,因为它是 Django 的默认数据库管理系统。然而,在实际的用户应用程序中,SQLite 几乎从不使用,因为它不像其他系统那样容易扩展。幸运的是,如果我们希望为我们的数据库运行一个单独的服务器,我们只需添加另一个 Docker 容器,并使用一个称为Docker Compose的功能将它们一起运行。这将允许两个不同的服务器在不同的容器中运行,同时还能相互通信。为了指定这一点,我们将使用一个名为docker-compose.yml的 YAML 文件:
version: '3'
services:
db:
image: postgres
web:
build: .
volumes:
- .:/usr/src/app
ports:
- "8000:8000"
在上面的文件中,我们:
-
指定我们使用 Docker Compose 的版本 3。
-
概述两个服务:
-
db根据 Postgres 已经编写好的镜像设置我们的数据库容器。 -
web通过指示 Docker 设置我们的服务器容器:-
在当前目录中使用 Dockerfile。
-
在容器中使用指定的路径。
-
将容器内的端口 8000 链接到我们电脑上的端口 8000。
-
-
现在,我们准备好使用命令docker-compose up启动我们的服务。这将启动我们两个服务器,并在新的 Docker 容器内运行。
在这一点上,我们可能想要在我们的 Docker 容器中运行命令来添加数据库条目或运行测试。为此,我们首先运行docker ps来显示所有正在运行的 Docker 容器。然后,我们将找到我们想要进入的容器的CONTAINER ID,并运行docker exec -it CONTAINER_ID bash -l。这将把你移动到我们在容器内设置的usr/src/app目录中。我们可以在那个容器内运行任何我们想要的命令,然后通过运行CTRL-D来退出。
这节课的内容就到这里!下次,我们将致力于扩展我们的项目并确保它们的安全性。
第八讲
-
简介
-
可扩展性
-
扩展
-
负载均衡
-
自动扩展
- 服务器故障
-
扩展数据库
- 数据库复制
-
缓存
-
安全
- Git 和 GitHub
-
HTML
-
HTTPS
-
密钥加密
-
公钥加密
-
-
数据库
-
APIs
-
环境变量
-
-
JavaScript
- 跨站请求伪造
-
接下来是什么?
简介
-
到目前为止,我们讨论了如何使用 HTML 和 CSS 构建简单的网页,以及如何使用 Git 和 GitHub 来跟踪我们代码的变化并与他人协作。我们还熟悉了 Python 编程语言,开始使用 Django 创建 Web 应用程序,并学习了如何使用 Django 模型在我们的网站上存储信息。然后我们介绍了 JavaScript,并学习了如何使用它使网页更加互动,并讨论了使用动画和 React 来进一步改进我们的用户界面。然后我们讨论了一些软件开发的最佳实践和一些常用于实现这些最佳实践的技术。
-
今天,在我们的最后一讲中,我们将讨论扩展和确保我们的 Web 应用程序安全的问题。
可扩展性
到目前为止,在本课程中,我们构建的应用程序仅在本地计算机上运行,但最终,我们希望发布我们的网站,以便任何互联网用户都可以访问。为了做到这一点,我们在服务器上运行我们的网站,这些服务器是专门用于运行应用程序的物理硬件。服务器可以是本地(我们拥有并维护物理服务器,我们的应用程序托管在其中)或云上(服务器由不同的公司拥有,如亚马逊或谷歌,我们支付租用服务器空间以托管我们的应用程序)。这两种选择都有其优点和缺点:
-
定制:托管自己的服务器让您能够决定它们如何工作,这比基于云的托管提供了更多的灵活性。
-
专业知识:在云上托管应用程序比维护自己的服务器要简单得多。
-
成本:由于服务器托管网站需要盈利,它们将向您收取比维护本地服务器成本更高的费用,这使得基于云的服务器更昂贵。然而,运行本地服务器的启动成本可能很高,因为您需要购买物理服务器,并可能需要聘请具有设置这些服务器所需专业知识的人。
-
可扩展性(Scalability): 当在云上托管时,扩展通常更容易。例如,如果我们托管一个每天有 500 次访问的本地网站,然后它开始每天有 500,000 次访问,我们就必须订购和设置更多的物理服务器来处理请求,同时许多用户将无法访问该网站。大多数云托管网站将允许你灵活地租用服务器空间,根据你的网站活动量来支付费用。
当用户向这个服务器发送 HTTP 请求时,服务器应该发送回一个响应。然而,在现实中,大多数服务器一次会接收到远超过一个请求,如下所示:

这就是我们会遇到可扩展性问题的地方。单个服务器一次只能处理这么多请求,迫使我们制定计划,当我们的一个服务器过载时我们将如何处理。无论我们决定在本地还是云上托管,我们都必须确定服务器可以处理而不崩溃的请求数量,这可以使用任何数量的基准测试工具来完成,包括 Apache Bench。
扩展
一旦我们确定我们的服务器可以处理多少请求的上限,我们就可以开始考虑我们想要如何处理应用程序的扩展。两种不同的扩展方法包括:
-
垂直扩展(Vertical Scaling): 在垂直扩展中,当我们的服务器过载时,我们只是购买或构建一个更大的服务器。然而,这种策略是有限的,因为单个服务器的强大程度有一个上限。
-
水平扩展(Horizontal Scaling): 在水平扩展中,当我们的服务器过载时,我们购买或构建更多的服务器,然后将请求分配给我们的多个服务器。
负载均衡
当我们使用水平扩展时,我们面临的一个额外问题是决定哪些服务器被分配给哪些请求。我们通过采用负载均衡器来回答这个问题,这是一种拦截传入请求并分配给我们的服务器的另一件硬件。有几种不同的方法来决定哪个服务器接收哪个请求,但这里有一些:
-
随机(Random): 在这个简单的方法中,负载均衡器将随机决定将请求分配给哪个服务器。
-
轮询(Round-Robin): 在这种方法中,负载均衡器将交替选择哪个服务器接收传入的请求。如果我们有三个服务器,第一个请求可能会发送到服务器 A,第二个发送到服务器 B,第三个发送到服务器 C,第四个又回到服务器 A。
-
最少连接(Fewest Connections): 在这种方法中,负载均衡器会寻找当前处理最少请求的服务器,并将传入的请求分配给该服务器。这确保了我们不会过度使用某个特定的服务器,但这也使得负载均衡器计算每个服务器当前处理的请求数量所需的时间比随机选择服务器要长。
没有一种负载均衡方法在所有其他方法中绝对优于其他方法,实践中使用了许多不同的方法。在水平扩展时可能出现的一个问题是,我们可能会有存储在一个服务器上的会话,但不在另一个服务器上,我们不希望用户因为负载均衡器将他们的请求推送到新的服务器而不得不重新输入信息。像许多可扩展性问题一样,解决会话问题的方法有多种:
-
粘性会话:一旦用户访问了一个网站,负载均衡器会记住他们最初被发送到哪个服务器,并确保将他们发送到同一个服务器。这种方法的一个主要担忧是,我们可能会让大量用户粘附在一个服务器上,导致该服务器崩溃。
-
数据库会话:所有会话都存储在一个所有服务器都可以访问的数据库中。这样,无论用户被分配到哪个服务器,他们的信息都将可用。这里的缺点是,从数据库中读取和写入需要额外的时间和计算能力。
-
客户端会话:而不是在我们的服务器上存储信息,我们可以选择将它们作为 cookie 存储在用户的网络浏览器中。这种方法的不利之处包括用户创建虚假 cookie 以允许他们以其他用户身份登录的安全问题,以及每次请求都要来回发送 cookie 信息的计算问题。
就像负载均衡一样,对于会话问题没有最好的答案,你选择的方法通常会取决于你的具体情况。
自动扩展
我们可能遇到另一个问题是,许多网站在特定时间访问频率要高得多。例如,如果我们决定从早些时候启动我们的“是新年吗?”应用程序,我们可能会预计它在年底到一月初的流量会比一年中的任何其他时间都要多。如果我们为网站购买足够的服务器以保持冬季的活跃状态,那么这些服务器在其余的时间里将处于闲置状态,浪费空间和能源。这种场景催生了自动扩展的概念,这在云计算中已成为常见做法,即网站使用的服务器数量可以根据接收到的请求数量增长和缩小。尽管如此,自动扩展并不是一个完美的解决方案,因为它需要时间来确定需要新的服务器并启动该服务器。另一个潜在的问题是,你拥有的运行服务器越多,出现故障的机会就越多。
服务器故障
虽然拥有多个服务器可以帮助避免所谓的单点故障,即一个硬件设备在故障后会导致整个网站崩溃。在水平扩展时,负载均衡器可以通过向每个服务器发送定期的心跳请求来检测哪些服务器已崩溃,然后停止将新请求分配给已崩溃的服务器。此时,似乎我们只是将单点故障从服务器转移到了负载均衡器,但我们可以通过备用负载均衡器的可用性来解决这个问题,以防原始负载均衡器意外崩溃。
数据库扩展
除了扩展处理请求的服务器外,我们还需要考虑如何扩展我们的数据库。在本课程中,我们使用 SQLite,它将数据存储在服务器上的文件中,但随着我们存储的数据越来越多,有时将数据存储在多个不同的文件中,甚至可能是在单独的服务器上,可能更有意义。这引发了一个问题,即当我们的数据库服务器无法处理所有传入的请求时应该怎么办。与其他可扩展性问题一样,我们可以使用多种方法来减轻这个问题:
-
垂直分区:这是一种与我们最初讨论 SQL 时使用的方法类似的方法,其中我们将数据拆分到多个不同的表中,而不是在一个表中保留冗余信息。(请随时回顾第四讲,其中我们将
flights表拆分为flights表和airports表)。 -
水平分区:这种方法涉及存储具有相同格式但不同信息的多个表。例如,我们可以将
flights表拆分为domestic_flights表和international_flights表。这样,当我们希望搜索从 JFK 到 LHR 的航班时,我们不必浪费时间搜索一个充满国内航班的表。这种方法的一个缺点是,一旦表被拆分,连接多个表可能会很昂贵。
数据库复制
即使我们在数据库进行了扩展,似乎我们仍然面临一个单点故障的问题。如果我们的数据库服务器崩溃,我们所有的数据可能会丢失。正如我们添加更多服务器以避免单点故障一样,我们也可以添加数据库的副本来确保一个数据库的故障不会使我们的应用程序关闭。同样,之前也有不同的数据库复制方法,其中两种最受欢迎的是:
- 单主复制:在这种方法中,有多个数据库,但只有一个被认为是主数据库,这意味着你可以从其中一个数据库中读取和写入,但只能从每个其他数据库中读取。当主数据库更新时,其他数据库随后更新以匹配主数据库。这种方法的一个缺点是,在写入数据库时仍然存在单点故障。

-
多主复制:在这种方法中,所有数据库都可以读取和写入。这解决了单点故障的问题,但代价是现在要使所有数据库保持最新状态变得更加困难,因为每个数据库都必须了解所有其他数据库的变化。这个系统也使我们面临一些冲突的可能性:
-
更新冲突:在多个数据库中,一个用户可能尝试在一个数据库中编辑一行,而另一个用户可能尝试在另一个数据库中编辑同一行,当数据库同步时,这会导致问题。
-
唯一性冲突:SQL 数据库中的每一行都必须有一个唯一的标识符,我们可能会遇到在两个不同的数据库中为两个不同的条目分配相同 ID 的问题。
-
删除冲突:一个用户可能删除一行,而另一个用户可能尝试更新它。
-

缓存
无论我们与大型数据库交互时,都应认识到每一次与数据库的交互都是昂贵的。因此,我们希望最小化对数据库服务器的调用次数。以纽约时报网站为例。纽约时报可能有一个包含所有文章的数据库,每次有人加载主页时都会查询该数据库,并渲染一些模板,但这样做会浪费资源,因为主页上显示的文章很可能每秒变化不大。我们可以通过使用缓存来解决这个问题,即如果我们预计在不久的将来需要再次使用某些信息,就将它们存储在更易于访问的位置。
缓存的一种实现方式是将数据存储在用户的网络浏览器中,这样当用户加载某些页面时,甚至不需要向服务器发送请求。实现这一点的办法之一是在 HTTP 响应的头部包含以下这一行:
Cache-Control: max-age=86400
这将告诉浏览器,当访问页面时,只要我在过去 86400 毫秒内访问过该页面,就不需要向服务器发送请求。这种方法通常用于浏览器,特别是对于不太可能在短时间内更改的文件,如 CSS 文件。为了更多地控制这个过程,我们还可以在 HTTP 响应头中添加一个ETag,它是一串唯一的字符序列,代表文档的特定版本。这很有用,因为未来的请求可以包含这个标签,并将其与服务器上最新文档的标签进行比较,只有当两者不同时才返回整个文档。
除了上面讨论的客户端缓存外,通常在服务器端包含一个缓存也很有帮助。有了这个缓存,我们的后端设置将类似于下面的一个,其中所有服务器都可以访问缓存。

Django 提供了一个自己的缓存框架,这将允许我们在项目中实现缓存。这个框架提供了几种实现缓存的方法:
-
视图级缓存:这允许我们决定一旦加载了特定的视图,该视图可以在不经过下一个指定时间内通过函数的情况下渲染。
-
模板片段缓存:这种缓存可以缓存模板的特定部分,这样它们就不需要重新渲染。例如,我们可能有一个很少改变的导航栏,这意味着我们可以通过不重新加载它来节省时间。
-
低级缓存 API:这允许你进行更灵活的缓存,本质上可以存储你想要的任何信息。
我们在这里不会详细介绍如何在 Django 中实现缓存,但如果您感兴趣,请查看文档!
安全
现在,我们将开始讨论如何确保我们的 Web 应用程序安全,这将涉及许多不同的措施,几乎涵盖了我们在本课程中讨论的几乎所有主题。
Git 和 GitHub
Git 和 GitHub 最大的优势之一是它们使共享和贡献开源软件变得非常容易,任何互联网用户都可以查看和贡献。这个缺点是,如果你在任何时候提交了一个包含一些私人凭证(如密码或 API 密钥)的文件,这些凭证可能会公开可用。
HTML
使用 HTML 会引发许多漏洞。其中一种常见的弱点被称为钓鱼攻击,当用户认为他们将要访问一个页面时,实际上却被带到了另一个页面。这些并不是我们在设计网站时可以预见的,但我们在自己与网络互动时应该肯定要考虑到它们。例如,一个恶意用户可能会编写以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Link</title>
</head>
<body>
<a href="https://cs50.harvard.edu/">https://www.google.com/</a>
</body>
</html>
它的作用如下:

HTML 实际上作为请求的一部分发送给用户,这增加了更多的漏洞,因为每个人都可以访问到允许你创建网站的布局和样式。例如,黑客可以访问bankofamerica.com,复制他们的所有 HTML,并将其粘贴到自己的网站上,创建一个看起来与美洲银行一模一样的网站。然后黑客可以重定向页面上的登录表单,使得所有用户名和密码都发送给他们。(此外,这里还有真正的美洲银行链接——只是想看看你在点击之前是否检查了 URL!)
HTTPS
如我们之前在课程中讨论的那样,大多数在线交互都遵循 HTTP 协议,尽管现在越来越多的交易使用 HTTPS,这是 HTTP 的加密版本。在使用这些协议时,信息通过一系列服务器以如图所示的方式从一个计算机传输到另一个计算机。

通常没有方法可以确保所有这些传输都是安全的,因此确保所有传输的信息都是加密的非常重要,这意味着消息的字符被改变,以便发送者和接收者可以理解它,但其他人不能。
秘密密钥密码学
一种处理方式被称为秘密密钥密码学。在这种方法中,发送者和接收者都拥有一个只有他们知道的秘密密钥。然后,发送者使用这个秘密密钥来加密一条消息,并将其发送给接收者,接收者使用秘密密钥来解密这条消息。这种方法非常安全,但当它涉及到实际应用时会产生一个大问题。为了使其工作,发送者和接收者都必须能够访问秘密密钥,这意味着他们必须亲自会面来安全地交换密钥。考虑到我们每天与不同网站互动的数量,很明显,面对面会面不是一个可行的选择。
公钥密码学
一种在密码学中令人难以置信的进步,使得互联网能够像今天这样运行,被称为公钥密码学。在这种方法中,有两个密钥:一个是公开的,可以共享,另一个必须保密。一旦这些密钥被建立(有几种不同的数学方法可以创建密钥对,这本身可以构成一门完整的课程,所以我们在这里不会讨论它们),发送者可以查找接收者的公钥并使用它来加密一条消息,然后接收者可以使用他们的私钥来解密这条消息。当我们使用 HTTPS 而不是 HTTP 时,我们知道我们的请求正在使用公钥加密来得到保护。
数据库
除了我们的请求和响应之外,我们还必须确保我们的数据库是安全的。我们需要存储的一个常见信息是用户信息,包括用户名和密码,如下表所示:

然而,你绝对不希望以明文形式存储密码,以防未经授权的人访问你的数据库。相反,我们将想要使用一个哈希函数,这是一个接受一些文本并输出一个看似随机的字符串的函数,为每个密码创建一个哈希值,如下表所示:

重要的是要注意,哈希函数是单向的,这意味着它可以将密码转换为哈希值,但不能将哈希值转换回密码。这意味着任何以这种方式存储用户信息的公司实际上并不知道任何用户的密码,这意味着每次用户尝试登录时,输入的密码将被哈希并与其现有的哈希值进行比较。幸运的是,这个过程已经被 Django 为我们处理了。这种存储技术的一个影响是,当用户忘记他们的密码时,公司无法告诉他们他们的旧密码是什么,这意味着他们必须创建一个新的密码。
有一些情况下,作为开发者,你必须决定你愿意泄露多少信息。例如,许多网站都有一个看起来像这样的忘记密码页面:

作为一名开发者,你可能在提交后想要包含成功或错误信息:


但请注意,通过输入电子邮件,任何人都可以确定谁在该网站上注册了电子邮件。在一个人是否使用该网站无关紧要的情况下(比如 Facebook),这可能完全没问题,但如果你是某个网站的成员可能会让你处于危险之中(比如虐待受害者的在线支持小组),这就会非常鲁莽。
数据可能泄露的另一种方式是在响应返回所需的时间。拒绝一个电子邮件地址无效的人可能比拒绝一个电子邮件地址正确但密码错误的人更快。
如我们在课程中之前讨论过的,每次我们在代码中使用直接的 SQL 查询时,都必须警惕 SQL 注入攻击。
APIs
我们经常将 JavaScript 与 API 结合使用来构建单页应用程序。当我们自己构建 API 时,我们可以使用一些方法来保持我们的 API 安全:
-
API 密钥:只处理你提供给 API 客户端的密钥的请求。
-
速率限制:限制任何用户在给定时间段内可以发出的请求数量。这有助于防止拒绝服务(DoS)攻击,恶意用户通过向你的 API 发出大量调用,使其崩溃。
-
路由认证:有许多情况下我们不希望让每个人都访问我们的所有数据,因此我们可以使用路由认证来确保只有特定的用户可以看到特定的数据。
环境变量
正如我们想要避免以明文形式存储密码一样,我们也会想要避免在我们的源代码中包含 API 密钥。避免这种情况的一种常见方法就是使用环境变量,或者存储在你的操作系统或服务器环境中的变量。然后,而不是在我们的源代码中包含一串文本,我们可以包含对环境变量的引用。
JavaScript
恶意用户可能会尝试使用 JavaScript 进行几种类型的攻击。一个例子是跨站脚本攻击,即用户在自己的网站上编写并运行自己的 JavaScript 代码。例如,让我们想象我们有一个 Django 应用程序,它有一个单一的 URL:
urlpatterns = [
path("<path:path>", views.index, name="index")
]
以及一个单一视图:
def index(request, path):
return HttpResponse(f"Requested Path: {path}")
该网站本质上告诉用户他们导航到的 URL 是什么:

但用户现在可以通过在 URL 中输入来轻松地将一些 JavaScript 插入页面:

虽然这个alert示例相对无害,但要包含一些操纵 DOM 或使用fetch发送请求的 JavaScript 并不会更困难。
跨站请求伪造
我们已经讨论了如何使用 Django 来防止 CSRF 攻击,但让我们看看没有这种保护会发生什么。作为一个例子,想象一家银行有一个你可以访问的 URL,可以从你的账户中转账。一个人可以轻松地创建一个链接来执行这种转账:
<a href="http://yourbank.com/transfer?to=brian&amt=2800">
Click Here!
</a>
这种攻击甚至可能比链接更微妙。如果 URL 被放在图片中,那么它将在浏览器尝试加载图片时被访问:
<img src="http://yourbank.com/transfer?to=brian&amt=2800">
由于这个原因,每次你构建一个可以接受某些状态变化的应用程序时,都应该使用 POST 请求。即使银行要求 POST 请求,隐藏表单字段仍然可以诱使用户意外提交请求。以下表单甚至不需要用户点击;它会自动提交!
<body onload="document.forms[0].submit()">
<form action="https://yourbank.com/transfer"
method="post">
<input type="hidden" name="to" value="brian">
<input type="hidden" name="amt" value="2800">
<input type="submit" value="Click Here!">
</form>
</body>
上述示例展示了跨站请求伪造可能的样子。我们可以通过在加载网页时创建 CSRF 令牌来阻止此类攻击,并且只接受带有有效令牌的表单。
接下来是什么?
我们在本课程中讨论了许多 Web 框架,如 Django 和 React,但还有更多你可能感兴趣的框架:
-
服务器端
-
客户端
在未来,你可能还希望能够通过多种不同的服务将你的网站部署到网络上:
我们自从这门课程开始以来已经走得很远了,覆盖了大量的材料,但在网络编程的世界里还有很多东西要学习。尽管有时可能会感到压倒性,但学习更多知识的最佳方法之一就是投身到一个项目中,看看你能将它推进多远。我们相信,在这个阶段,你在网络设计概念方面已经打下了坚实的基础,而且你已经拥有了将一个想法转化为你自己的工作网站所需的一切!


浙公网安备 33010602011771号