7.8.2 用于新建清单的URL和视图
下面添加新的 URL 映射:
superlists/urls.py
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list'),
]
再运行测试,会得到 no attribute'new_list'
错误。修正这个问题,在文件 lists/views.py 中写入:
lists/views.py (ch07l023-1)
def new_list(request):
pass
再运行测试,得到的失败消息是:“The view lists.views.new_list didn't return an HttpResponse object”(lists.views.new_list
视图没返回 HttpResponse
对象)。这个消息很眼熟。虽然可以返回一个原始的 HttpResponse
对象,但既然知道需要的是重定向,那就从 home_page
视图中借用一行代码吧:
lists/views.py (ch07l023-2)
def new_list(request):
return redirect('liststhe-only-list-in-the-world/')
现在测试的结果是:
self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
失败消息简洁易懂。再从 home_page
视图中借用一行代码:
lists/views.py (ch07l023-3)
def new_list(request):
Item.objects.create(text=request.POST['item_text'])
return redirect('liststhe-only-list-in-the-world/')
加入这行代码后,测试便能通过了:
Ran 7 tests in 0.030s
OK
而且功能测试表明,我们又回到了正常状态:
[...]
AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
milk']
Ran 2 tests in 8.972s
FAILED (failures=1)
7.8.3 删除当前多余的代码和测试
看起来不错。既然新视图完成了以前 home_page
视图的大部分工作,应该就可以大幅度精简 home_page
了。比如说,可以删除整个 if request.method == 'POST'
部分吗?
lists/views.py
def home_page(request):
return render(request, 'home.html')
当然可以!
OK
既然已经动手简化了,还可以把当前多余的测试方法 test_only_saves_items_when_necessary
也删掉。
删掉之后是不是感觉挺好的?视图函数变得更简洁了。再次运行测试,确认一切正常:
Ran 6 tests in 0.016s
OK
那功能测试呢?
7.8.4 出现回归!让表单指向刚添加的新URL
糟糕:
ERROR: test_can_start_a_list_for_one_user
[...]
File "...superlists/functional_tests/tests.py", line 57, in
test_can_start_a_list_for_one_user
self.wait_for_row_in_list_table('1: Buy peacock feathers')
File "...superlists/functional_tests/tests.py", line 23, in
wait_for_row_in_list_table
table = self.browser.find_element_by_id('id_list_table')
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]
ERROR: test_multiple_users_can_start_lists_at_different_urls
[...]
File "...superlists/functional_tests/tests.py", line 79, in
test_multiple_users_can_start_lists_at_different_urls
self.wait_for_row_in_list_table('1: Buy peacock feathers')
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]
[...]
Ran 2 tests in 11.592s
FAILED (errors=2)
这是因为表单依然指向旧的 URL。在 home.html 和 lists.html 中,把表单改成:
lists/templates/home.html, lists/templates/list.html
<form method="POST" action="listsnew">
这样就能回到之前的状态了:
AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
milk']
[...]
FAILED (failures=1)
以上操作可以作为一次完整的提交:对 URL 映射做了些改动,views.py 看起来也精简多了,而且能保证应用还能像以前那样正常运行。我们的重构技术变得越来越好了!
- $ git status # 5个改动的文件
- $ git diff # 在两个表单中添加了URL,视图和测试都有代码移动,还添加了一个新URL
- $ git commit -a
可以在待办事项清单中划掉一个问题了:
7.9 下定决心,调整模型
关于 URL 的清理工作做得够多了,现在下定决心修改模型。先调整模型的单元测试。这次换种方式,以差异的形式表示改动的地方:
lists/tests.py
@@ -1,5 +1,5 @@
from django.test import TestCase
-from lists.models import Item
+from lists.models import Item, List
class HomePageTest(TestCase):
@@ -44,22 +44,32 @@ class ListViewTest(TestCase):
-class ItemModelTest(TestCase):
+class ListAndItemModelsTest(TestCase):
def test_saving_and_retrieving_items(self):
+ list_ = List()
+ list_.save()
+
first_item = Item()
first_item.text = 'The first (ever) list item'
+ first_item.list = list_
first_item.save()
second_item = Item()
second_item.text = 'Item the second'
+ second_item.list = list_
second_item.save()
+ saved_list = List.objects.first()
+ self.assertEqual(saved_list, list_)
+
saved_items = Item.objects.all()
self.assertEqual(saved_items.count(), 2)
first_saved_item = saved_items[0]
second_saved_item = saved_items[1]
self.assertEqual(first_saved_item.text, 'The first (ever) list item')
+ self.assertEqual(first_saved_item.list, list_)
self.assertEqual(second_saved_item.text, 'Item the second')
+ self.assertEqual(second_saved_item.list, list_)
新建了一个 List
对象,然后通过给 .list
属性赋值把两个待办事项归在这个对象名下。要检查这个清单是否正确保存,也要检查是否保存了那两个待办事项与清单之间的关系。你还会注意到可以直接比较两个清单(saved_list
和 list_
)——其实比较的是两个清单的主键(.id
属性)是否相同。
我使用变量名
list_
的目的是防止遮盖 Python 原生的list
函数。这么写可能不美观,但我能想到的其他写法也同样不美观,或者更糟,比如my_list
、the_list
、list1
和listey
等。
现在要开始另一个“单元测试 / 编写代码”循环了。
在前几次迭代中,我只给出每次运行测试时期望看到的错误消息,不会告诉你运行测试前要输入哪些代码,你要自己编写每次所需的最少代码改动。
需要提示?翻回第 5 章,参照引入
Item
模型的步骤。
你首先看到的错误消息是:
ImportError: cannot import name 'List'
解决这个错误后,再次运行测试会看到:
AttributeError: 'List' object has no attribute 'save'
然后会看到:
django.db.utils.OperationalError: no such table: lists_list
因此需要执行一次 makemigrations
命令:
- $ python manage.py makemigrations
- Migrations for 'lists':
- lists/migrations/0003_list.py
- - Create model List
然后会看到:
self.assertEqual(first_saved_item.list, list_)
AttributeError: 'Item' object has no attribute 'list'
7.9.1 外键关系
Item
的 list
属性应该怎么实现呢?先天真一点,把它当成 text
属性试试(你可以借机看一下你的实现方式与我的有什么区别):
lists/models.py
from django.db import models
class List(models.Model):
pass
class Item(models.Model):
text = models.TextField(default='')
list = models.TextField(default='')
照例,测试会告诉我们需要做一次迁移:
- $ python manage.py test lists
- [...]
- django.db.utils.OperationalError: no such column: lists_item.list
- $ python manage.py makemigrations
- Migrations for 'lists':
- lists/migrations/0004_item_list.py
- - Add field list to item
看一下测试结果如何:
AssertionError: 'List object' != <List: List object>
离成功还有一段距离。请仔细看 !=
两边的内容。Django 只保存了 List
对象的字符串形式。若想保存对象之间的关系,要告诉 Django 两个类之间的关系,这种关系使用 ForeignKey
字段表示:
lists/models.py
from django.db import models
class List(models.Model):
pass
class Item(models.Model):
text = models.TextField(default='')
list = models.ForeignKey(List, default=None)
修改之后也要做一次迁移。既然前一个迁移没用了,就把它删掉吧,换一个新的:
- $ rm lists/migrations/0004_item_list.py
- $ python manage.py makemigrations
- Migrations for 'lists':
- lists/migrations/0004_item_list.py
- - Add field list to item
删除迁移是种危险操作,但偶尔需要这么做,因为不可能从一开始就正确定义模型。如果删除已经用于某个数据库的迁移,Django 就不知道当前状态,因此也就不知道如何运行以后的迁移。只有当你确定某个迁移没被使用时才能将其删除。根据经验,已经提交到 VCS 的迁移决不能删除。
7.9.2 根据新模型定义调整其他代码
再看测试的结果如何:
- $ python manage.py test lists
- [...]
- ERROR: test_displays_all_items (lists.tests.ListViewTest)
- django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
- [...]
- ERROR: test_redirects_after_POST (lists.tests.NewListTest)
- django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
- [...]
- ERROR: test_can_save_a_POST_request (lists.tests.NewListTest)
- django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
- Ran 6 tests in 0.021s
- FAILED (errors=3)
天啊,这么多错误。
可是也有一些好消息。虽然很难看出,不过模型测试通过了。但是三个视图测试出现了重大错误。
出现这些错误是因为我们在待办事项和清单之间建立了关联,在这种关联中,每个待办事项都需要一个父级清单,但是原来的测试和代码并没有考虑到这一点。
不过,这正是测试的目的所在。下面要让测试再次通过。最简单的方法是修改 ListViewTest
,为测试中的两个待办事项创建父清单:
lists/tests.py (ch07l031)
class ListViewTest(TestCase):
def test_displays_all_items(self):
list_ = List.objects.create()
Item.objects.create(text='itemey 1', list=list_)
Item.objects.create(text='itemey 2', list=list_)
修改之后,失败测试减少到两个,而且都是向 new_list
视图发送 POST 请求引起的。使用惯用的技术分析调用跟踪,由错误消息找到导致错误的测试代码,然后再找出相应的应用代码,最终定位到下面这行:
File "...superlistslistsviews.py", line 9, in new_list
Item.objects.create(text=request.POST['item_text'])
这行调用跟踪表明创建待办事项时没有指定父清单。因此,要对视图做类似修改:
lists/views.py
from lists.models import Item, List
[...]
def new_list(request):
list_ = List.objects.create()
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect('liststhe-only-list-in-the-world/')
修改之后,测试又能通过了:
Ran 6 tests in 0.030s
OK
此时你是不是感觉不舒畅?我们为每个新建的待办事项都指定了所属的清单,但还是集中显示所有待办事项,好像它们都属于同一个清单似的——感觉这么做完全不对。我知道不对,我也有同样的感觉。我们采用的步进方式与直觉不一致,要求代码从一个可用状态变成另一个可用状态。我总想直接动手一次修正所有问题,而不想把一个奇怪的半成品变成另一个半成品。可是你还记得测试山羊吗?爬山时,你要审慎抉择每一步踏在何处,而且一次只能迈一步,确认脚踩的每一个位置都不会让你跌落悬崖。
因此,为了确信一切都能正常运行,要再次运行功能测试:
AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
milk']
[...]
毫无疑问,测试的结果和修改前一样。现有功能没有破坏,在此基础上还修改了数据库。这一点令人欣喜!下面提交:
- $ git status # 改动了3个文件,还新建了2个迁移
- $ git add lists
- $ git diff --staged
- $ git commit
又可以从待办事项清单上划掉一个问题了:
7.10 每个列表都应该有自己的URL
应该使用什么作为清单的唯一标识符呢?就目前而言,或许最简单的处理方式是使用数据库自动生成的 id
字段。下面修改 ListViewTest
,让其中的两个测试指向新 URL。
还要把 test_displays_all_items
测试重命名为 test_displays_only_items_for_that_list
,然后在这个测试中确认只显示属于这个清单的待办事项:
lists/tests.py (ch07l033)
class ListViewTest(TestCase):
def test_uses_list_template(self):
list_ = List.objects.create()
response = self.client.get(f'lists{list_.id}/')
self.assertTemplateUsed(response, 'list.html')
def test_displays_only_items_for_that_list(self):
correct_list = List.objects.create()
Item.objects.create(text='itemey 1', list=correct_list)
Item.objects.create(text='itemey 2', list=correct_list)
other_list = List.objects.create()
Item.objects.create(text='other list item 1', list=other_list)
Item.objects.create(text='other list item 2', list=other_list)
response = self.client.get(f'lists{correct_list.id}/')
self.assertContains(response, 'itemey 1')
self.assertContains(response, 'itemey 2')
self.assertNotContains(response, 'other list item 1')
self.assertNotContains(response, 'other list item 2')
这个代码清单又用到了几个 f 字符串。如果你还是不太了解,看一下文档(https://docs.python.org/3/reference/lexical_analysis.html#f-strings)。(如果你跟我一样没正式学习过 CS,或许应该跳过正式的语法。)
运行这个单元测试,会看到预期的 404,以及另一个相关的错误:
FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404
(expected 200)
[...]
FAIL: test_uses_list_template (lists.tests.ListViewTest)
AssertionError: No templates used to render the response
7.10.1 捕获URL中的参数
现在要学习如何把 URL 中的参数传入视图:
superlists/urls.py
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/(.+)/$', views.view_list, name='view_list'),
]
调整 URL 映射中使用的正则表达式,加入一个捕获组(capture group,.+
),它能匹配随后 /
之前的任意个字符。捕获得到的文本会作为参数传入视图。
也就是说,如果访问 lists1/,view_list
视图除了常规的 request
参数之外,还会获得第二个参数,即字符串 "1"
。如果访问 listsfoo/,视图就是 view_list(request, "foo")
。
但是视图并未期待有参数传入,毫无疑问,这么做会导致问题:
ERROR: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
[...]
TypeError: view_list() takes 1 positional argument but 2 were given
[...]
ERROR: test_uses_list_template (lists.tests.ListViewTest)
[...]
TypeError: view_list() takes 1 positional argument but 2 were given
[...]
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
[...]
TypeError: view_list() takes 1 positional argument but 2 were given
FAILED (errors=3)
这个问题容易修正,在 views.py 中加入一个参数即可:
lists/views.py
def view_list(request, list_id):
[...]
现在,前面那个预期失败解决了:
FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
[...]
AssertionError: 1 != 0 : Response should not contain 'other list item 1'
接下来要让视图决定把哪些待办事项传入模板:
lists/views.py
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
items = Item.objects.filter(list=list_)
return render(request, 'list.html', {'items': items})
7.10.2 按照新设计调整new_list
视图
现在得到发生在另一个测试中的错误:
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
ValueError: invalid literal for int() with base 10:
'the-only-list-in-the-world'
既然这个测试报错了,就来看看它的代码吧:
lists/tests.py
class NewListTest(TestCase):
[...]
def test_redirects_after_POST(self):
response = self.client.post('listsnew', data={'item_text': 'A new list item'})
self.assertRedirects(response, 'liststhe-only-list-in-the-world/')
看样子这个测试还没按照清单和待办事项的新设计调整,它应该检查视图是否重定向到指定新建清单的 URL:
lists/tests.py (ch07l036-1)
def test_redirects_after_POST(self):
response = self.client.post('listsnew', data={'item_text': 'A new list item'})
new_list = List.objects.first()
self.assertRedirects(response, f'lists{new_list.id}/')
修改后测试还是得到无效字面量错误。检查一下视图本身,把它改为重定向到有效的地址:
lists/views.py (ch07l036-2)
def new_list(request):
list_ = List.objects.create()
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect(f'lists{list_.id}/')
这样修改之后单元测试就可以通过了:
- $ python3 manage.py test lists
- [...]
- ......
- ---------------------------------------------------------------------
- Ran 6 tests in 0.033s
- OK
那么功能测试结果如何?差不多也能通过吧?
7.11 功能测试又检测到回归
嗯,快了:
F.
F.
FAIL: test_can_start_a_list_for_one_user
(functional_tests.tests.NewVisitorTest)
Traceback (most recent call last):
File "…superlists/functional_tests/tests.py", line 67, in
test_can_start_a_list_for_one_user
self.wait_for_row_in_list_table('2: Use peacock feathers to make a fly')
[…]
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Use
peacock feathers to make a fly']
Ran 2 tests in 8.617s
FAILED (failures=1)
新测试其实通过了,不同的用户有不同的清单,但是旧测试提醒我们出现了回归。看起来无法在清单中添加第二个待办事项。这是因为我们投机取巧了,每次 POST 提交都新建一个清单。这正是编写功能测试的目的!
而这正好和待办事项清单中最后一个问题高度吻合:
7.12 还需要一个视图,把待办事项加入现有清单
还需要一个 URL 和视图(lists/add_item),把新待办事项添加到现有的清单中。我们已经熟知这种操作了,因此可以一次写好两个测试:
lists/tests.py
class NewItemTest(TestCase):
def test_can_save_a_POST_request_to_an_existing_list(self):
other_list = List.objects.create()
correct_list = List.objects.create()
self.client.post(
f'lists{correct_list.id}/add_item',
data={'item_text': 'A new item for an existing list'}
)
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new item for an existing list')
self.assertEqual(new_item.list, correct_list)
def test_redirects_to_list_view(self):
other_list = List.objects.create()
correct_list = List.objects.create()
response = self.client.post(
f'lists{correct_list.id}/add_item',
data={'item_text': 'A new item for an existing list'}
)
self.assertRedirects(response, f'lists{correct_list.id}/')
你是不是觉得奇怪,想知道为什么要用
other_list
?这与查看某个清单的测试类似,要确保把待办事项添加到特定的清单中。在数据库中再存储一个对象便无须使用List.objects.first()
这样可能出错的代码。那样做是不对的,如果真做了,你可能会为之付出惨痛代价(毕竟数字有无穷多个)。这只是主观选择,不过我觉得值得这么做。详情请参见 15.1.1 节。
测试的结果为:
AssertionError: 0 != 1
[...]
AssertionError: 301 != 302 : Response didn't redirect as expected: Response
code was 301 (expected 302)
7.12.1 小心霸道的正则表达式
有点奇怪,还没在 URL 映射中加入 lists1/add_item,应该得到 404 != 302
错误,怎么会是 301 呢?
确实令人费解。其实得到这个错误是因为在 URL 映射中使用了一个非常霸道的正则表达式:
superlists/urls.py
url(r'^lists/(.+)/$', views.view_list, name='view_list'),
根据 Django 的内部处理机制,如果访问的 URL 几乎正确,但却少了末尾的斜线,就会得到一个永久重定向响应(301)。在这里,lists1/add_item/ 符合 lists/(.+)/
的匹配模式,其中 (.+)
捕获 1/add_item
,所以 Django 伸出“援手”,猜测你其实是想访问末尾带斜线的 URL。
这个问题的修正方法是,显式指定 URL 模式只捕获数字,即在正则表达式中使用 \d:
superlists/urls.py
url(r'^lists/(\d+)/$', views.view_list, name='view_list'),
修改后测试的结果是:
AssertionError: 0 != 1
[...]
AssertionError: 404 != 302 : Response didn't redirect as expected: Response
code was 404 (expected 302)
7.12.2 最后一个新URL
现在得到了预期的 404。下面定义一个新 URL,用于把新待办事项添加到现有清单中:
superlists/urls.py
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/(\d+)/$', views.view_list, name='view_list'),
url(r'^lists/(\d+)/add_item$', views.add_item, name='add_item'),
]
现在 URL 映射中定义了三个类似的 URL。在待办事项清单中做个记录,因为这三个 URL 看起来需要重构。
再看测试,现在又提示视图模块缺少属性:
AttributeError: module 'lists.views' has no attribute 'add_item'
7.12.3 最后一个新视图
定义下面这个视图试试:
lists/views.py
def add_item(request):
pass
效果不错:
TypeError: add_item() takes 1 positional argument but 2 were given
继续修改视图:
lists/views.py
def add_item(request, list_id):
pass
测试的结果是:
ValueError: The view lists.views.add_item didn't return an HttpResponse object.
It returned None instead.
可以从 new_list
视图中复制 redirect
,从 view_list
视图中复制 List.objects.get
:
lists/views.py
def add_item(request, list_id):
list_ = List.objects.get(id=list_id)
return redirect(f'lists{list_.id}/')
现在测试的结果为:
self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
最后,让视图保存新建的待办事项:
lists/views.py
def add_item(request, list_id):
list_ = List.objects.get(id=list_id)
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect(f'lists{list_.id}/')
这样,测试又能通过了:
Ran 8 tests in 0.050s
OK
7.12.4 直接测试响应上下文对象
把待办事项添加到现有清单所需的视图和 URL 都有了,现在只剩在 list.html 模板中使用它们了。打开这个模板,修改表单标签:
lists/templates/list.html
<form method="POST" action="but what should we put here?">
可是,若想获取添加到当前清单的 URL,模板要知道它渲染的是哪个清单,以及要添加哪些待办事项。希望表单能写成下面这样:
lists/templates/list.html
<form method="POST" action="lists{{ list.id }}/add_item">
为了能这样写,视图要把清单传入模板。下面在 ListViewTest 中新建一个单元测试方法:
lists/tests.py (ch07l041)
def test_passes_correct_list_to_template(self):
ther_list = List.objects.create()
orrect_list = List.objects.create()
esponse = self.client.get(f'lists{correct_list.id}/')
elf.assertEqual(response.context['list'], correct_list) ➊
➊ response.context
表示要传入 render
函数的上下文——Django 测试客户端把上下文附在 response
对象上,方便测试。
增加这个测试后得到的结果如下:
KeyError: 'list'
这是因为没把 list
传入模板,其实也给了我们一个简化视图的机会:
lists/views.py
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
return render(request, 'list.html', {'list': list_})
这么做显然会破坏一个旧测试,因为模板需要 items
:
FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
[...]
AssertionError: False is not true : Couldn't find 'itemey 1' in response
可以在 list.html 中修正这个问题,同时还要修改表单 POST 请求的目标地址,即 action
属性:
lists/templates/list.html (ch07l043)
<form method="POST" action="lists{{ list.id }}/add_item"> ➊
[...]
{% for item in list.item_set.all %} ➋
<tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
{% endfor %}
➊ 这是新的目标地址。
➋ .item_set
叫作反向查询(reverse lookup),是 Django 提供的非常有用的 ORM 功能,作用是在其他表中查询某个对象的相关记录。
修改模板之后,单元测试能通过了:
Ran 9 tests in 0.040s
OK
功能测试的结果如何呢?
- $ python manage.py test functional_tests
- [...]
- ..
- ---------------------------------------------------------------------
- Ran 2 tests in 9.771s
- OK
太好了!再看一下待办事项清单:
可惜,测试山羊也是善始不善终的,还有最后一个问题没解决。
在解决这个问题之前,先做提交——着手重构之前一定要提交可正常运行的代码:
- $ git diff
- $ git commit -am "new URL + view for adding to existing lists. FT passes :-)"
7.13 使用URL引入做最后一次重构
superlists/urls.py 的真正作用是定义整个网站使用的 URL。如果某些 URL 只在 lists
应用中使用,Django 建议使用单独的文件 lists/urls.py,让应用自成一体。定义 lists
使用的 URL,最简单的方法是复制现有的 urls.py:
- $ cp superlists/urls.py lists/
然后把 superlists/urls.py 中的三行定义换成一个 include
:
superlists/urls.py
from django.conf.urls import include, url
from lists import views as list_views ➊
from lists import urls as list_urls ➊
urlpatterns = [
url(r'^$', list_views.home_page, name='home'),
url(r'^lists/', include(list_urls)), ➋
]
❶ 顺便使用 import x as y
句法为视图和 URL 映射创建别名。在顶层 urls.py 中,这是个好做法,便于从多个应用中引入视图和 URL 映射。其实,后文就会这么做。
❷ 这是那个 include
。注意,include
可以使用一个正则表达式作为 URL 的前缀,这个前缀会添加到引入的所有 URL 前面(这就是去除重复的方法,同时也让代码结构更清晰)。
回到 lists/urls.py 中,我们只需写入那三个 URL 的后半部分,而且不用再写父级 urls.py 中的其他定义:
lists/urls.py(ch07l046)
from django.conf.urls import url
from lists import views
urlpatterns = [
url(r'^new$', views.new_list, name='new_list'),
url(r'^(\d+)/$', views.view_list, name='view_list'),
url(r'^(\d+)/add_item$', views.add_item, name='add_item'),
]
再次运行单元测试,确认一切仍能正常运行。
我修改时,怀疑自己的能力,不确信能一次改对,所以特意一次只改一个 URL,防止测试失败。如果改错了,还有测试提醒我们。
你可以动手试一下。记得要改回来,而且要确认测试全部都能通过,然后再提交:
- $ git status
- $ git add lists/urls.py
- $ git add superlists/urls.py
- $ git diff --staged
- $ git commit
终于结束了,这一章可真长啊。我们讨论了很多重要话题,先从测试隔离开始,然后又思考了设计。介绍了一些经验法则,比如“YAGNI”和“事不过三,三则重构”。最重要的是,看到了如何一步步修改现有网站,从一个可运行状态变成另一个可运行状态,逐渐实现新设计。
不得不说,我们的网站已经非常接近发布状态了,也就是说,这个待办事项清单网站的首个测试版可以公之于众了。不过,在此之前可能还要做些美化。在接下来的几章中,我们要介绍部署网站时需要做些什么。
更多 TDD 哲学
从一个可运行状态到另一个可运行状态(又叫测试山羊与重构猫)
本能经常驱使我们直接动手一次修正所有问题,但如果不小心,最终可能像重构猫一样,改动了很多代码但都不起作用。测试山羊建议我们一次只迈一步,从一个可运行状态走到另一个可运行状态。
把工作分解成易于实现的小任务
有时,我们要从“乏味的”工作入手,而不是直指有趣的任务。你要相信人只能活一次,平行宇宙中的另一个你可能过得并不好,把功能都破坏了,极尽所能想让应用再次运行起来。
YAGNI
“You ain't gonna need it”(你不需要这个)的简称,劝诫你不要受诱惑编写当时看起来可能有用的代码。很有可能你根本用不到这些代码,或者没有准确预见未来的需求。第 22 章给出了一种方法,可以让你避免落入这个陷阱。
第二部分 Web 开发要素
“真正的开发者一定会发布自己的产品。”
——Jeff Atwood
如果这是一本普通编程领域内的 TDD 入门书,到这里我们就可以庆贺一番了,毕竟我们已经掌握了扎实的 TDD 和 Django 基础,也具备了开始开发网站所需的一切知识。
但是,真正的开发者一定会发布自己的产品,那就无法回避 Web 开发中的一些棘手问题,比如静态文件、表单数据验证、可怕的 JavaScript 等,但最令人惧怕的还是部署到生产服务器。
在每个阶段,TDD 都能协助我们正确处理这些问题。
在这一部分中,我仍会尽量让学习曲线保持平缓,而且我们会学到多个重要的新概念和技术。我不会深入展开每个话题,只是希望我所演示的方法足够你在自己的项目中开始使用。如果真想在实际工作中使用这些技术,你还得做些扩展阅读。
如果你开始阅读本书之前并不熟悉 Django,现在花点时间读一遍 Django 官方教程,能很好地巩固目前所学的知识。熟悉 Django 的相关概念后,你会更自信,在接下来的几章中,能专注于我要讲的核心概念。
这一部分有很多有趣的知识,敬请期待!
第 8 章 美化网站:布局、样式及其测试方法
我们正考虑要发布网站的第一个版本,但让人尴尬的是,网站现在看起来还很简陋。本章介绍一些样式基础知识,包括如何集成 Bootstrap 这个 HTML/CSS 框架,还要学习 Django 处理静态文件的方式,以及如何测试静态文件。
8.1 如何在功能测试中测试布局和样式
不可否认,我们的网站现在没有太大的吸引力(如图 8-1)。
图 8-1:首页,有点简陋
执行命令
manage.py runserver
启动开发服务器时,可能会看到一个数据库错误:“table lists_item has no column named list_id”(lists_item 表中没有名为 list_id 的列)。此时,需要执行manage.py migrate
命令,更新本地数据库,让 models.py 中的改动生效。如果提醒IntegrityErrors
,就删除 1 数据库文件,然后再试。
1什么?删除数据库?疯了吗?并没有。在开发的过程中,本地开发数据库经常与迁移不同步,而且里面也没什么重要数据,因此可以放心删除。不过要谨慎对待生产服务器中的数据库,详情请参见附录 D。
既然不参加 Python 世界的选丑竞赛,就要美化这个网站。或许我们想实现如下效果。
- 一个精美且很大的输入框,用于新建清单,或者把待办事项添加到现有的清单中。
- 把这个输入框放在一个更大的居中框体中,吸引用户的注意力。
应该怎么使用 TDD 实现这些效果呢?大多数人都会告诉你,不要测试外观。他们是对的,这就像是测试常量一样毫无意义。
但可以测试装饰外观的方式,确信实现了预期的效果即可。例如,使用层叠样式表(Cascading Style Sheet,CSS)编写样式,样式表以静态文件的形式加载,而静态文件配置起来有点儿复杂(稍后会看到,把静态文件移到主机上,配置起来更麻烦),因此只需做某种“冒烟测试”(smoke test),确保加载了 CSS 即可。无须测试字体、颜色以及像素级位置,而是通过简单的测试,确认重要的输入框在每个页面中都按照预期的方式对齐,由此推断页面中的其他样式或许也都正确应用了。
先在功能测试中编写一个新测试方法:
functional_tests/tests.py (ch08l001)
class NewVisitorTest(LiveServerTestCase):
[...]
def test_layout_and_styling(self):
# 伊迪丝访问首页
self.browser.get(self.live_server_url)
self.browser.set_window_size(1024, 768)
# 她看到输入框完美地居中显示
inputbox = self.browser.find_element_by_id('id_new_item')
self.assertAlmostEqual(
inputbox.location['x'] + inputbox.size['width'] / 2,
512,
delta=10
)
这里有些新知识。先把浏览器的窗口设为固定大小,然后找到输入框元素,获取它的大小和位置,再做些数学计算,检查输入框是否位于网页的中线上。assertAlmostEqual
的作用是帮助处理舍入误差以及偶尔由滚动条等事物导致的异常,这里指定计算结果在正负 10 像素范围内为可接受。
运行功能测试会得到如下结果:
- $ python manage.py test functional_tests
- [...]
- .F.
- ======================================================================
- FAIL: test_layout_and_styling (functional_tests.tests.NewVisitorTest)
- ---------------------------------------------------------------------
- Traceback (most recent call last):
- File "...superlists/functional_tests/tests.py", line 129, in
- test_layout_and_styling
- delta=10
- AssertionError: 107.0 != 512 within 10 delta
- ---------------------------------------------------------------------
- Ran 3 tests in 9.188s
- FAILED (failures=1)
这次失败在预料之中。不过,这种功能测试很容易出错,所以要用一种有点作弊的快捷方法确认输入框居中时功能测试能通过。一旦确认功能测试编写正确之后,就把这些代码删掉:
lists/templates/home.html (ch08l002)
<form method="POST" action="listsnew">
<p style="text-align: center;">
<input name="item_text" id="id_new_item" placeholder="Enter a to-do item" >
<p>
{% csrf_token %}
</form>
修改之后测试能通过,说明功能测试起作用了。下面扩展这个测试,确保新建清单后输入框仍然居中对齐显示:
functional_tests/tests.py (ch08l003)
# 她新建了一个清单,看到输入框仍完美地居中显示
inputbox.send_keys('testing')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: testing')
inputbox = self.browser.find_element_by_id('id_new_item')
self.assertAlmostEqual(
inputbox.location['x'] + inputbox.size['width'] / 2,
512,
delta=10
)
这会导致测试再次失败:
File "...superlists/functional_tests/tests.py", line 141, in
test_layout_and_styling
delta=10
AssertionError: 107.0 != 512 within 10 delta
现在只提交功能测试:
- $ git add functional_tests/tests.py
- $ git commit -m "first steps of FT for layout + styling"
现在,似乎找到了满足需求的适当解决方案,能更好地样式化网站。那么就退回添加
- $ git reset --hard
![]()
git reset --hard
是一个破坏力极强的 Git 命令,它会还原所有没提交的改动,所以使用时要小心。它和几乎所有其他 Git 命令都不同,执行之后无法撤销操作。
8.2 使用CSS框架美化网站
设计不简单,现在更难,因为要处理手机、平板等设备。所以很多程序员,尤其是像我一样的懒人,都转而使用 CSS 框架解决问题。框架有很多,不过出现最早且最受欢迎的是 Twitter 开发的 Bootstrap。我们就使用这个框架。
Bootstrap 可在 http://getbootstrap.com/ 获取。
下载 Bootstrap,把它放在 lists
应用中一个新文件夹 static 里:2
2在 Windows 中不能使用 wget
和 unzip
,但我相信你知道如何下载 Bootstrap,解压缩,再把 dist 文件夹中的内容移到 listsstaticbootstrap 文件夹中。
- $ wget -O bootstrap.zip https://github.com/twbs/bootstrap/releasesdownload\
- v3.3.4/bootstrap-3.3.4-dist.zip
- $ unzip bootstrap.zip
- $ mkdir lists/static
- $ mv bootstrap-3.3.4-dist listsstaticbootstrap
- $ rm bootstrap.zip
dist 文件夹中的内容是未经定制的原始 Bootstrap 框架,现在使用这些内容,但在真正的网站中不能这么做,因为用户能立即看出你使用 Bootstrap 时没有定制,业内人士也能由此推知你懒得为网站编写样式。你应该学习如何使用 LESS,至少把字体改了。Bootstrap 文档中有定制的详细说明,或者你可以阅读一篇名为“How to Customize Twitter's Bootstrap”的指南,写得还不错。
最终得到的 lists 文件夹结构如下:
- $ tree lists
- lists
- ├── __init__.py
- ├── __pycache__
- │ └── [...]
- ├── admin.py
- ├── models.py
- ├── static
- │ └── bootstrap
- │ ├── css
- │ │ ├── bootstrap.css
- │ │ ├── bootstrap.css.map
- │ │ ├── bootstrap.min.css
- │ │ ├── bootstrap-theme.css
- │ │ ├── bootstrap-theme.css.map
- │ │ └── bootstrap-theme.min.css
- │ ├── fonts
- │ │ ├── glyphicons-halflings-regular.eot
- │ │ ├── glyphicons-halflings-regular.svg
- │ │ ├── glyphicons-halflings-regular.ttf
- │ │ ├── glyphicons-halflings-regular.woff
- │ │ └── glyphicons-halflings-regular.woff2
- │ └── js
- │ ├── bootstrap.js
- │ ├── bootstrap.min.js
- │ ├── npm.js
- ├── templates
- │ ├── home.html
- │ └── list.html
- ├── tests.py
- ├── urls.py
- └── views.py
在 Bootstrap 文档中的“Getting Started”部分,你会发现 Bootstrap 要求 HTML 模板中包含如下代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bootstrap 101 Template</title>
<!-- Bootstrap -->
<link href="css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<h1>Hello, world!</h1>
<script src="http://code.jquery.com/jquery.js"></script>
<script src="js/bootstrap.min.js"></script>
</body>
</html>
我们已经有两个 HTML 模板了,所以不想在每个模板中都添加大量的样板代码。这似乎是运用“不要自我重复”原则的好时机,可以把通用代码放在一起。谢天谢地,Django 使用的模板语言可以轻易做到这一点,这种功能叫作“模板继承”。
8.3 Django模板继承
看一下 home.html 和 list.html 之间的差异:
- $ diff lists/templates/home.html lists/templates/list.html
- < <h1>Start a new To-Do list</h1>
- < <form method="POST" action="listsnew">
- ---
- > <h1>Your To-Do list</h1>
- > <form method="POST" action="lists{{ list.id }}/add_item">
- [...]
- > <table id="id_list_table">
- > {% for item in list.item_set.all %}
- > <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
- > {% endfor %}
- > </table>
这两个模板头部显示的文本不一样,而且表单的提交地址也不同。除此之外,list.html 还多了一个
现在我们弄清了两个模板之间共通以及有差异的地方,然后就可以让它们继承同一个父级模板了。先复制 home.html:
- $ cp lists/templates/home.html lists/templates/base.html
把通用的样板代码写入这个基模板中,而且标记出各个“块”,块中的内容留给子模板定制:
lists/templates/base.html
<html>
<head>
<title>To-Do lists</title>
</head>
<body>
<h1>{% block header_text %}{% endblock %}</h1>
<form method="POST" action="{% block form_action %}{% endblock %}">
<input name="item_text" id="id_new_item" placeholder="Enter a to-do item" >
{% csrf_token %}
<form>
{% block table %}
{% endblock %}
</body>
</html>
基模板定义了多个叫作“块”的区域,其他模板可以在这些地方插入自己所需的内容。在实际操作中看一下这种机制的用法。修改 home.html,让它继承 base.html:
lists/templates/home.html
{% extends 'base.html' %}
{% block header_text %}Start a new To-Do list{% endblock %}
{% block form_action %}listsnew{% endblock %}
可以看出,很多 HTML 样板代码都不见了,只需集中精力编写想定制的部分。然后对 list.html 做同样的修改:
lists/templates/list.html
{% extends 'base.html' %}
{% block header_text %}Your To-Do list{% endblock %}
{% block form_action %}lists{{ list.id }}/add_item{% endblock %}
{% block table %}
<table id="id_list_table">
{% for item in list.item_set.all %}
<tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
{% endfor %}
</table>
{% endblock %}
对模板来说,这是一次重构。再次运行功能测试,确保没有破坏现有功能:
AssertionError: 107.0 != 512 within 10 delta
果然,结果和修改前一样。这次改动值得做一次提交:
- $ git diff -b
- # -b的意思是忽略空白,因为我们修改了HTML代码中的一些缩进,所以有必要使用这个旗标
- $ git status
- $ git add lists/templates # 先不添加static文件夹
- $ git commit -m "refactor templates to use a base template"
8.4 集成Bootstrap
现在集成 Bootstrap 所需的样板代码更容易了,不过暂时不需要 JavaScript,只加入 CSS 即可:
lists/templates/base.html (ch08l006)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>To-Do lists</title>
<link href="css/bootstrap.min.css" rel="stylesheet">
</head>
[...]
行和列
最后,使用 Bootstrap 中某些真正强大的功能。使用之前你得先阅读 Bootstrap 的文档。可以使用栅格系统和 text-center
类实现所需的效果:
lists/templates/base.html (ch08l007)
<body>
<div class="container">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="text-center">
<h1>{% block header_text %}{% endblock %}</h1>
<form method="POST" action="{% block form_action %}{% endblock %}">
<input name="item_text" id="id_new_item"
placeholder="Enter a to-do item" >
{% csrf_token %}
<form>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% block table %}
{% endblock %}
</div>
</div>
</div>
</body>
(如果你从来没有把一个 HTML 标签分成多行,可能会觉得上述 标签有点儿打破常规。这样写完全可行,如果你不喜欢,可以不这么写。)
如果你从未看过 Bootstrap 文档,花点儿时间浏览一下吧。文档中介绍了很多有用的工具,可以运用到你的网站中。
做了上述修改之后,功能测试能通过吗?
AssertionError: 107.0 != 512 within 10 delta
嗯,还不能。为什么没有加载 CSS 呢?
8.5 Django中的静态文件
Django 处理静态文件时需要知道两件事(其实所有 Web 服务器都是如此)。
(1) 收到指向某个 URL 的请求时,如何区分请求的是静态文件,还是需要经过视图函数处理,生成 HTML。
(2) 到哪里去找用户请求的静态文件。
其实,静态文件就是把 URL 映射到硬盘中的文件上。
为了解决第一个问题,Django 允许我们定义一个 URL 前缀,任何以这个前缀开头的 URL 都被视作针对静态文件的请求。默认的前缀是 static,在 settings.py 中定义:
superlists/settings.py
[...]
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.11/howto/staticfiles/
STATIC_URL = 'static'
后面在这一部分添加的设置都和第二个问题有关,即在硬盘中找到真正的静态文件。
既然用的是 Django 开发服务器(manage.py runserver
),就可以把寻找静态文件的任务交给 Django 完成。Django 会在各应用中每个名为 static 的子文件夹里寻找静态文件。
现在知道把 Bootstrap 框架的所有静态文件都放在 lists/static 文件夹中的原因了吧。可是为什么现在不起作用呢?因为没在 URL 中加入前缀 static。再看一下 base.html 中链接 CSS 的元素:
<link href="css/bootstrap.min.css" rel="stylesheet">
若想让这行代码起作用,要把它改成:
lists/templates/base.html
<link href="staticbootstrap/css/bootstrap.min.css" rel="stylesheet">
现在,开发服务器收到这个请求时就知道请求的是静态文件,因为 URL 以 static 开头。然后,服务器在每个应用中名为 static 的子文件夹里搜寻名为 bootstrap/css/bootstrap.min.css 的文件。最后找到的文件应该是 listsstaticbootstrap/css/bootstrap.min.css。
如果手动在浏览器中查看,应该会看到样式起作用了,如图 8-2 所示。
图 8-2:网站开始变得有点好看了
换用StaticLiveServerTestCase
不过,功能测试还无法通过:
AssertionError: 107.0 != 512 within 10 delta
这是因为,虽然 runserver
启动的开发服务器能自动找到静态文件,但 LiveServerTestCase
找不到。不过无须担心,Django 为开发者提供了一个更神奇的测试类,叫 StaticLiveServerTestCase。
下面换用这个测试类:
functional_tests/tests.py
@@ -1,14 +1,14 @@
-from django.test import LiveServerTestCase
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.keys import Keys
import time
MAX_WAIT = 10
-class NewVisitorTest(LiveServerTestCase):
+class NewVisitorTest(StaticLiveServerTestCase):
def setUp(self):
现在测试能找到 CSS 了,因此测试也能通过了:
- $ python manage.py test functional_tests
- Creating test database for alias 'default'...
- ...
- ---------------------------------------------------------------------
- Ran 3 tests in 9.764s
Windows 用户在这里可能会看到一些错误消息(无关紧要,但容易让人分心):
socket.error: [WinError 10054] An existing connection was forcibly closed by the remote host
(现有连接被远程主机强制关闭)。在tearDown
方法的self.browser.quit()
之前加上self.browser.refresh()
就能去掉这些错误。Django 的追踪系统正在关注这个问题。
太好了!
8.6 使用Bootstrap中的组件改进网站外观
看一下使用 Bootstrap 百宝箱中的其他工具能否进一步改善网站的外观。
8.6.1 超大文本块
Bootstrap 中有个类叫 jumbotron
,用于特别突出地显示页面中的元素。使用这个类放大显示页面的主头部和输入表单:
lists/templates/base.html (ch08l009)
<div class="col-md-6 col-md-offset-3 jumbotron">
<div class="text-center">
<h1>{% block header_text %}{% endblock %}</h1>
<form method="POST" action="{% block form_action %}{% endblock %}">
[...]
调整网页的设计和布局时,可以打开一个浏览器窗口,时不时地刷新页面。执行
python manage.py runserver
命令启动开发服务器,然后在浏览器中访问 http://localhost:8000,边改边看效果。
8.6.2 大型输入框
超大文本块是个好的开始,不过和其他内容相比,输入框显得太小。幸好 Bootstrap 为表单控件提供了一个类,可以把输入框变大:
lists/templates/base.html (ch08l010)
<input name="item_text" id="id_new_item"
class="form-control input-lg"
placeholder="Enter a to-do item" />
8.6.3 样式化表格
现在表格中的文字和页面中的其他文字相比也很小,加上 Bootstrap 提供的 table
类可以改进显示效果:
lists/templates/list.html (ch08l011)
<table id="id_list_table" class="table">
8.7 使用自己编写的CSS
最后,我想让输入表单离标题文字远一点儿。Bootstrap 没有提供现成的解决方案,那么就自己实现吧,引入一个自己编写的 CSS 文件:
lists/templates/base.html
[...]
<title>To-Do lists</title>
<link href="staticbootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="staticbase.css" rel="stylesheet">
</head>
新建文件 listsstaticbase.css,写入自己编写的 CSS 新规则。使用输入框的 id
(id_new_item
)定位元素,然后为其编写样式:
listsstaticbase.css
#id_new_item {
margin-top: 2ex;
}
虽然要多操作几步,不过效果很让我满意(如图 8-3)。
图 8-3:清单页面,各部分都显示得很大
如果想进一步定制 Bootstrap,需要编译 LESS。我强烈推荐你花时间定制,总有一天你会有这种需求。LESS、Sass 等其他伪 CSS 类工具,对普通的 CSS 做了很大改进,即便不使用 Bootstrap 也很有用。我不会在这本书中介绍 LESS,网上有很多参考资料,比如说“How to Customize Twitter's Bootstrap”。
最后再运行一次功能测试,看一切是否仍能正常运行:
- $ python manage.py test functional_tests
- [...]
- ...
- ---------------------------------------------------------------------
- Ran 3 tests in 10.084s
- OK
样式化暂告段落。现在是提交的绝好时机:
- $ git status # 修改了tests.py、base.html和list.html,未跟踪lists/static
- $ git add .
- $ git status # 会显示添加了所有Bootstrap相关文件
- $ git commit -m "Use Bootstrap to improve layout"
8.8 补遗:collectstatic
命令和其他静态目录
前文说过,Django 的开发服务器会自动在应用的文件夹中查找并呈现静态文件。在开发过程中这种功能不错,但在真正的 Web 服务器中,并不需要让 Django 伺服静态内容,因为使用 Python 伺服原始文件速度慢而且效率低,Apache、Nginx 等 Web 服务器能更好地完成这项任务。或许你还会决定把所有静态文件都上传到 CDN(Content Distribution Network,内容分发网络),不放在自己的主机中。
鉴于此,要把分散在各个应用文件夹中的所有静态文件集中起来,复制一份放在一个位置,为部署做好准备。collectstatic
命令的作用就是完成这项操作。
静态文件集中放置的位置由 settings.py 中的 STATIC_ROOT
定义。下一章会做些部署工作,现在就试着设置一下吧。把 STATIC_ROOT
的值设为仓库之外的一个文件夹——我要使用和主源码文件夹同级的一个文件夹:
workspace
│ ├── superlists
│ │ ├── lists
│ │ │ ├── models.py
│ │ │
│ │ ├── manage.py
│ │ ├── superlists
│ │
│ ├── static
│ │ ├── base.css
│ │ ├── etc...
关键在于,静态文件所在的文件夹不能放在仓库中——不想把这个文件夹纳入版本控制,因为其中的文件和 lists/static 中的一样。
下面是指定这个文件夹位置的一种优雅方式,路径相对 settings.py 文件而言:
superlists/settings.py (ch08l018)
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.11/howto/staticfiles/
STATIC_URL = 'static'
STATIC_ROOT = os.path.abspath(os.path.join(BASE_DIR, '../static'))
在设置文件的顶部,你会看到定义了 BASE_DIR
变量。这个变量利用 __file__
(这是 Python 内置的变量,特别有用),为我们提供了很大的便利。
下面执行 collectstatic
命令试试:
- $ python manage.py collectstatic
- [...]
- Copying '...superlistslistsstatic/bootstrap/css/bootstrap-theme.css'
- Copying '...superlistslistsstatic/bootstrap/css/bootstrap.min.css'
- 76 static files copied to '...static'.
如果查看 ../static,会看到所有的 CSS 文件:
- $ tree ..static
- ..static
- ├── admin
- │ ├── css
- │ │ ├── base.css
- [...]
- │ └── xregexp.min.js
- ├── base.css
- └── bootstrap
- ├── css
- │ ├── bootstrap.css
- │ ├── bootstrap.css.map
- │ ├── bootstrap.min.css
- │ ├── bootstrap-theme.css
- │ ├── bootstrap-theme.css.map
- │ └── bootstrap-theme.min.css
- ├── fonts
- │ ├── glyphicons-halflings-regular.eot
- │ ├── glyphicons-halflings-regular.svg
- │ ├── glyphicons-halflings-regular.ttf
- │ ├── glyphicons-halflings-regular.woff
- │ └── glyphicons-halflings-regular.woff2
- └── js
- ├── bootstrap.js
- ├── bootstrap.min.js
- └── npm.js
- 14 directories, 76 files
collectstatic
命令还收集了管理后台的所有 CSS 文件。管理后台是 Django 的强大功能之一,总有一天我们要知道它的全部功能。但现在还不准备用,所以暂且禁用:
superlists/settings.py
INSTALLED_APPS = [
#'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'lists',
]
然后再执行 collectstatic
试试:
- $ rm -rf ..static
- $ python manage.py collectstatic --noinput
- Copying '...superlistslistsstatic/base.css'
- [...]
- Copying '...superlistslistsstatic/bootstrap/css/bootstrap-theme.css'
- Copying '...superlistslistsstatic/bootstrap/css/bootstrap.min.css'
- 15 static files copied to '...static'.
好多了。
总之,现在知道了怎么把所有静态文件都聚集到一个文件夹中,这样 Web 服务器就能轻易找到静态文件。下一章会深入介绍这个功能和测试方法。
现在,先提交 settings.py 中的改动:
- $ git diff # 会看到settings.py中的改动
- $ git commit -am "set STATIC_ROOT in settings and disable admin"
8.9 没谈到的话题
本章只简单介绍了样式化和 CSS,有一些话题我本想涉及,但没做到。你可以进一步研究以下话题。
- 使用 LESS 或 Sass 定制 Bootstrap。
- 使用
{% static %}
模板标签,这样做更符合 DRY 原则,也不用硬编码 URL。 - 客户端打包工具,例如 npm 和 bower。
总结:如何测试设计和布局
简单来说,不应该为设计和布局编写测试。因为这太像是测试常量,所以写出的测试不太牢靠。
这说明设计和布局的实现过程极具技巧性,涉及 CSS 和静态文件。因此,可以编写一些简单的“冒烟测试”,确认静态文件和 CSS 起作用即可。下一章我们会看到,把代码部署到生产环境时,冒烟测试能协助我们发现问题。
但是,如果某部分样式需要很多客户端 JavaScript 代码才能使用(我花了很多时间实现动态缩放),就必须为此编写一些测试。
要试着编写最简的测试,确信设计和布局能起作用即可,不必测试具体的实现。我们的目标是能自由修改设计和布局,且无须时不时地调整测试。
第 9 章 使用过渡网站测试部署
“所有乐趣都在部署到生产环境之前。”
——Devops Borat
是时候发布网站的首个版本让公众使用了。人们常说,如果等到一切就绪再发布,等待的时间就太长了。
我们的网站有用吗?是不是比没有要好?能在这个网站上创建待办事项清单吗?这三个疑问的答案都是肯定的。
可是,现在还无法登录,也无法把任务标记为已完成。不过你真的需要这些功能吗?不一定,因为你永远也不知道用户真正想使用你的网站做什么。我们觉得用户应该想使用这个网站制定待办事项清单,但他们真正想编写的或许是一个“十佳假蝇钓鱼地”列表,因此就不用提供“标记为已完成”功能。在发布之前,我们永远不知道用户的真实需求。
本章会详细介绍如何把网站部署到真实的线上 Web 服务器中。
你可能想跳过这一章,因为本章有很多令人生畏的知识,或许还觉得部署不是你阅读本书的初衷。但是我强烈建议你读一下。本章是我最满意的内容之一,而且有很多读者写信说很庆幸自己当时克服困难读完了这一章。
如果你以前从未把网站部署到服务器上,读过本章后会发现一片新大陆,而且没有什么能比看到自己的网站在真正的互联网中上线更令人满足的了。如果你还迟疑,现今流行的术语,比如“开发运维”(DevOps),一定能让你相信,花时间学习部署是值得的。
你的网站上线后请写封信告诉我网址。收到这样的来信,我的心里总是感觉暖暖的。我的电子邮件地址是 obeythetestinggoat@gmail.com。
9.1 TDD以及部署的危险区域
把网站部署到线上 Web 服务器是个很复杂的过程。我们经常会听到这样凄苦的抱怨:“但在我的设备中可以正常运行啊!”
部署过程中的一些危险区域如下。
- 静态文件(CSS、JavaScript、图片等)
Web 服务器往往需要特殊的配置才能伺服静态文件。
- 数据库
可能会遇到权限和路径问题,还要小心处理,在多次部署之间不能丢失数据。
- 依赖
要保证服务器上安装了网站依赖的包,而且版本要正确。
不过这些问题都有相应的解决方案。下面一一说明。
- 使用与生产环境一样的基础架构部署过渡网站(staging site),这么做可以测试部署的过程,确保部署真正的网站时操作正确。
- 可以在过渡网站中运行功能测试,确保服务器中安装了正确的代码和依赖包。而且为了测试网站的布局,我们编写了冒烟测试,这样就能知道是否正确加载了 CSS。
- 与在本地设备上一样,当服务器上运行多个 Python 应用时,可以使用虚拟环境管理包和依赖。
- 最后,一切操作都自动化完成。使用自动化脚本部署新版本,使用同一个脚本把网站部署到过渡环境和生产环境,这么做能尽量保证过渡网站和线上网站一样。 1
1我称之为“过渡”服务器,有人叫它“开发”服务器,还有人叫它“预备生产”服务器。不管叫什么,目的都是架设一个尽量和生产服务器一样的环境来测试代码。
在接下来的几页中,我会详细说明一个部署过程。这不是最佳的部署过程,所以别把它当作最佳实践,也别当作是推荐做法。只是做个演示,告诉你部署过程涉及哪些问题,以及测试在这个过程中的作用。
内容提要
接下来的三章内容很多,这里列出提要,帮助你理清思路。
本章:搭建基础环境
- 修改功能测试,以便在过渡服务器中运行。
- 架设服务器,安装所需的全部软件,再把过渡和线上环境使用的域名指向这个服务器。
- 使用 Git 把代码上传到服务器。
- 使用 Django 开发服务器在过渡环境的域名下尝试运行过渡网站。
- 自己动手在服务器上搭建虚拟环境(不用
virtualenvwrapper
),确保数据库和静态文件都能使用。- 在这个过程中不断运行功能测试,找出哪些功能可以正常运行,哪些不能。
下一章:针对生产环境配置
- 修改配置,使其适应生产环境:不再使用 Django 开发服务器,让应用在引导时自动启动,把
DEBUG
设为False
,等等。关于部署的第三章:自动部署
(1) 配置好之后,编写一个脚本,自动执行前面手动完成的操作,这样以后就能自动部署网站了。
(2) 最后,使用自动化脚本把网站的生产版本部署到真正的域名下。
9.2 一如既往,先写测试
稍微修改一下功能测试,让它能在过渡网站中运行。添加一个参数,指定测试所用的临时服务器地址:
functional_tests/tests.py (ch08l001)
import os
[...]
class NewVisitorTest(StaticLiveServerTestCase):
def setUp(self):
self.browser = webdriver.Firefox()
staging_server = os.environ.get('STAGING_SERVER') ➊
if staging_server:
self.live_server_url = 'http://' + staging_server ➋
还记得我说过 LiveServerTestCase
有一定的缺陷吗?其中一个缺陷是,它总是假定你想使用它自己的测试服务器,这个服务器的地址通过 self.live_server_url
获取。有时我确实想使用这个测试服务器,但也想有别的选择,比如使用一个真正的服务器。
❶ 我通过环境变量 STAGING_SERVER
决定使用哪个服务器。
❷ 我采用的措施是,把 self.live_server_url
替换成“真实”服务器的地址。
按照“常规的”方式运行功能测试,确保上述改动没有破坏现有功能:
- $ python manage.py test functional_tests
- [...]
- Ran 3 tests in 8.544s
- OK
然后指定过渡服务器的 URL 再运行试试。我要使用的过渡服务器地址是 superlists-staging.ottg.eu:
- $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
- ======================================================================
- FAIL: test_can_start_a_list_for_one_user
- (functional_tests.tests.NewVisitorTest)
- ---------------------------------------------------------------------
- Traceback (most recent call last):
- File "...superlists/functional_tests/tests.py", line 49, in
- test_can_start_a_list_and_retrieve_it_later
- self.assertIn('To-Do', self.browser.title)
- AssertionError: 'To-Do' not found in 'Domain name registration | Domain names
- | Web Hosting | 123-reg'
- [...]
- ======================================================================
- FAIL: test_multiple_users_can_start_lists_at_different_urls
- (functional_tests.tests.NewVisitorTest)
- ---------------------------------------------------------------------
- Traceback (most recent call last):
- File
- "...superlists/functional_tests/tests.py", line 86, in
- test_layout_and_styling
- inputbox = self.browser.find_element_by_id('id_new_item')
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: [id="id_new_item"]
- [...]
- ======================================================================
- FAIL: test_layout_and_styling (functional_tests.tests.NewVisitorTest)
- ---------------------------------------------------------------------
- Traceback (most recent call last):
- File
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: [id="id_new_item"]
- [...]
- Ran 3 tests in 19.480s:
- FAILED (failures=3)
如果在 Windows 上看到“STAGING_SERVER is not recognized as a command”这样的错误,可能是因为你没使用 Git-Bash。请再看一下“准备工作和应具备的知识”。
可以看到,和预期一样,两个测试都失败了,因为我还没有域名呢。实际上,从第一个调用跟踪中可以看出,访问域名注册商的网站首页时测试就失败了。
不过,看起来功能测试的测试对象是正确的,所以做一次提交吧:
- $ git diff # 会显示functional_tests.py中的改动
- $ git commit -am "Hack FT runner to be able to test staging"
别使用
export
设定STAGING_SERVER
环境变量,否则在当前终端里运行的后续测试都会在过渡网站中运行(如果这跟你的预期不一样,会让你十分困惑)。最好在每次运行功能测试时明确设定。
9.3 注册域名
读到这里,我们需要注册几个域名,不过也可以使用同一个域名下的多个二级域名。我要使用的域名是 superlists.ottg.eu 和 superlists-staging.ottg.eu。如果你还没有域名,现在就得注册一个。再次说明,我真的希望你按我所说的做。如果你从未注册过域名,随便选一个老牌注册商买一个便宜的就行,只要花 5 美元左右,甚至还能找到免费域名。我说过想在真正的网站中看到你的应用,这或许能推动你去注册一个域名。
9.4 手动配置托管网站的服务器
可以把部署的过程分成两个任务。
- 配置新服务器,用于托管代码。
- 把新版代码部署到配置好的服务器中。
有些人喜欢每次部署都用全新服务器,我们在 PythonAnywhere 就是这么做的。不过这种做法只适用于大型的复杂网站,或者对现有网站做了重大修改。对我们这个简单的网站来说,分别完成上述两个任务更合理。虽然最终这两个任务都要完全自动化,但就目前而言或许更适合手动配置。
在阅读过程中,你要记住,配置的过程各异,因此部署有很多通用的最佳实践。所以,与其尝试记住我的具体做法,不如试着理解其中的基本原理,这样以后你遇到具体问题时就能使用相同的思想解决了。
9.4.1 选择在哪里托管网站
现今,托管网站有大量不同的方案,不过基本上可以归纳为两类。
- 运行自己的服务器(可能是虚拟服务器)。
- 使用“平台即服务”(Platform-As-A-Service,PaaS)提供商,例如 Heroku、OpenShift 或 PythonAnywhere。
对小型网站而言,PaaS 的优势尤其明显,我强烈建议你考虑使用 PaaS。不过,基于几个原因,本书不会使用 PaaS。首先,有利益冲突,我觉得 PythonAnywhere 是最棒的,但我这么说是因为我在这家公司工作。其实,各家 Paas 提供商提供的支持各不相同,部署的过程也不一样,所以学会其中一家的部署方法并不能在别家使用。任何一家提供商都有可能完全修改部署过程,或者当你阅读这本书时已经停业了。
因此,我们要学习一些优秀的老式服务器管理方法,包括 SSH 和 Web 服务器配置。这些方法永远不会过时,而且学会这些方法还能从头发花白的老前辈那儿得到一些尊重。
我要试着搭建一个十分类似于 PaaS 环境的服务器,不管以后你选择哪种配置方案,都能用到这个部署过程中学到的知识。
9.4.2 搭建服务器
我不规定你该怎么搭建服务器,不管你选择使用 Amazon AWS、Rackspace 或 Digital Ocean,还是自己的数据中心里的服务器,抑或楼梯后橱柜里的 Raspberry Pi——哪种方案都行,只要满足以下条件即可。
- 服务器的系统使用 Ubuntu16.04(“Xenial/LTS”)。
- 有访问服务器的 root 权限。
- 外网可访问。
- 可以通过 SSH 登录。
我推荐使用 Ubuntu 发行版是因为其中安装了 Python 3.6,而且有一些配置 Nginx 的特殊方式(后文会用到)。如果你知道自己在做什么,或许可以换用其他发行版,但遇到问题只能靠自己了。
如果你从未用过 Linux 服务器,完全不知道从何处入手,可以参照我在 GitHub 上写的一篇简要指南(https://github.com/hjwp/Book-TDD-Web-Dev-Python/blob/master/server-quickstart.md)。
有些人阅读本章时会跳过购买域名和架设真正的服务器这两部分,直接在自己的电脑中使用虚拟机。请不要这么做,这两种方式不一样。配置服务器本身已经很复杂了,如果用虚拟机,阅读本章的内容时会遇到更大的困难。如果你担心要花钱,可以四处找找,域名和服务器都有免费的。如果需要进一步指导,可以给我发电子邮件,我一直都乐于助人。
9.4.3 用户账户、SSH和权限
本节假定你的用户账户没有 root 权限,但有使用 sudo 的权限,因此执行需要 root 权限的操作时,我们可以使用 sudo。在下面的说明中,如果需要用 sudo,我会指出来。
我使用的用户名是“elspeth”,你可以根据自己的喜好起名。
9.4.4 安装Nginx
我们需要一个 Web 服务器,既然现在酷小孩都使用 Nginx,那我们也用它吧。我和 Apache 斗争了多年,可以说单就配置文件的可读性这一项而言,Nginx 如同神赐般拯救了我们。
在我的服务器中安装 Nginx 只需执行一次 apt-get
命令即可,然后再执行一个命令就能看到 Nginx 默认的欢迎页面:
- elspeth@server:$ sudo apt-get install nginx
- elspeth@server:$ sudo systemctl start nginx
(你可能要先执行 apt-get update
和 / 或 apt-get upgrade
。)
注意本章命令行代码清单中的 elspeth@server,它表示命令必须在服务器中执行,而不是在你自己的电脑中执行。
现在访问服务器的 IP 地址就能看到 Nginx 的“Welcome to nginx”(欢迎使用 Nginx)页面,如图 9-1 所示。
图 9-1:Nginx 可用了
如果没看到这个页面,可能是因为防火墙没有开放 80 端口。以 AWS 为例,你可能得配置服务器的“Security Group”才能打开 80 端口。
9.4.5 安装Python 3.6
写作本书时,Ubuntu 的标准仓库中还没有 Python 3.6,但是用户贡献的“Deadsnakes PPA”中有。
既然我们有 root 权限,下面就来安装所需的系统级关键软件:Python、Git、pip 和 virtualenv。
- elspeth@server:$ sudo add-apt-repository ppa:fkrull/deadsnakes
- elspeth@server:$ sudo apt-get update
- elspeth@server:$ sudo apt-get install python3.6 python3.6-venv
顺便把 Git 也安装上:
- elspeth@server:$ sudo apt-get install git
9.4.6 解析过渡环境和线上环境所用的域名
不想总是使用 IP 地址,所以要把过渡环境和线上环境所用的域名解析到服务器上。我的注册商提供的控制面板如图 9-2 所示。
图 9-2:解析域名
在 DNS 系统中,把域名指向一个确切的 IP 地址叫作“A 记录”。各注册商提供的界面有所不同,但在你的注册商网站中四处点击几次应该就能找到正确的页面。
9.4.7 使用功能测试确认域名可用而且Nginx正在运行
为了确认一切顺利,可以再次运行功能测试。你会发现失败消息稍微有点儿不同,其中一个消息和 Nginx 有关:
- $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: [id="id_new_item"]
- [...]
- AssertionError: 'To-Do' not found in 'Welcome to nginx!'
有进展。鼓励一下自己,你可以泡杯茶,吃块巧克力饼干。
9.5 手动部署代码
接着要让过渡网站运行起来,检查 Nginx 和 Django 之间能否通信。从这一步起,配置结束了,进入“部署”阶段。在部署的过程中,要思考如何自动化这些操作。
区分配置阶段和部署阶段有个经验法则:配置时需要 root 权限,但部署时不需要。
需要一个文件夹来存放源码。我们把这个目录放在一个非 root 用户的家目录中,在我的服务器中,路径是 homeelspeth(好像所有共享主机的系统都是这么设置的。不论使用什么主机,一定要以非 root 用户身份运行 Web 应用)。我要按照下面的文件结构存放网站的代码:
homeelspeth
├── sites
│ ├── www.live.my-website.com
│ │ ├── database
│ │ │ └── db.sqlite3
│ │ ├── source
│ │ │ ├── manage.py
│ │ │ ├── superlists
│ │ │ ├── etc...
│ │ │
│ │ ├── static
│ │ │ ├── base.css
│ │ │ ├── etc...
│ │ │
│ │ └── virtualenv
│ │ ├── lib
│ │ ├── etc...
│ │
│ ├── www.staging.my-website.com
│ │ ├── database
│ │ ├── etc...
每个网站(过渡网站,线上网站或其他网站)都放在各自的文件夹中。在各文件夹中又有单独的子文件夹,分别存放源码、数据库和静态文件。采用这种结构的逻辑依据是,不同版本的网站源码可能会变,但数据库始终不变。静态文件夹也在同一个相对位置,即 ../static,前一章末尾我们已经设置好了。最后,virtualenv 也有自己的子文件夹(在服务器上无须使用 virtualenvwrapper
,我们将动手创建虚拟环境)。
9.5.1 调整数据库的位置
首先,在 settings.py 中修改数据库的位置,而且要保证修改后的位置在本地电脑中也能使用:
superlists/settings.py (ch08l003)
# 项目内的路径使用os.path.join(BASE_DIR, ...)形式构建
import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
[...]
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, '../database/db.sqlite3'),
}
}
看一下 settings.py 文件顶部
BASE_DIR
的定义。注意,最内层是abspath
。处理路径时一定要这么做,否则导入文件时会遇到各种奇怪的问题。感谢 Green Nathan 指出这一点。
在本地试一下:
- $ mkdir ../database
- $ python manage.py migrate --noinput
- Operations to perform:
- Apply all migrations: auth, contenttypes, lists, sessions
- Running migrations:
- [...]
- $ ls ../database/
- db.sqlite3
看起来可以正常使用。做次提交:
- $ git diff # 会看到settings.py中的改动
- $ git commit -am "move sqlite database outside of main source tree"
借助代码分享网站,使用 Git 把代码上传到服务器。如果之前没推送,现在要把代码推送到 GitHub、BitBucket 或同类网站中。这些网站都为初学者提供了很好的用法说明,告诉你该怎么推送。
把源码上传到服务器所需的全部 Bash 命令如下所示。如果你不熟悉 Bash 命令,我告诉你,export
的作用是创建一个可在 bash 中使用的“本地变量”:
- elspeth@server:$ export SITENAME=superlists-staging.ottg.eu
- elspeth@server:$ mkdir -p ~/sites/$SITENAME/database
- elspeth@server:$ mkdir -p ~/sites/$SITENAME/static
- elspeth@server:$ mkdir -p ~/sites/$SITENAME/virtualenv
- # 要把下面这行命令中的URL换成你自己仓库的URL
- elspeth@server:$ git clone https://github.com/hjwp/bookexample.git \
- ~/sites/$SITENAME/source
- Resolving deltas: 100% [...]
使用
export
定义的 Bash 变量只在当前终端会话中有效。如果退出服务器后再登录,就需要重新定义。这个特性有点隐晦,因为 Bash 不会报错,而是直接用空字符串表示未定义的变量,这种处理方式会导致诡异的结果。如果不信,可以执行echo $SITENAME
试试。
现在网站安装好了,在开发服务器中运行试试——这是一个冒烟测试,检查所有活动部件是否连接起来了:
- elspeth@server:$ $ cd ~/sites/$SITENAME/source
- $ python manage.py runserver
- Traceback (most recent call last):
- File "manage.py", line 8, in <module>
- from django.core.management import execute_from_command_line
- ImportError: No module named django.core.management
啊,服务器上还没安装 Django。
9.5.2 手动创建虚拟环境,使用requirements.txt
为了“保存”虚拟环境所需的包列表,也为了能够重建服务器,我们要创建 requirements.txt 文件:
- $ echo "django==1.11" > requirements.txt
- $ git add requirements.txt
- $ git commit -m "Add requirements.txt for virtualenv"
你可能觉得奇怪,为什么没在需要的依赖列表中添加 Selenium ?这是因为 Selenium 只是测试的依赖,而不是应用代码的依赖。有些人喜欢再创建一个名为 test-requirements.txt 的文件。
现在执行 git push
命令,把更新推送到代码分享网站:
- $ git push
然后,把改动拉取到服务器上:
- elspeth@server:$ git pull # 可能会让你先对git做些配置
若想手动创建虚拟环境(即不使用 virtualenvwrapper
),就要使用标准库中的 venv
模块,指定虚拟环境的存放路径:
- elspeth@server:$ pwd
- homeespeth/sites/staging.superlists.com/source
- elspeth@server:$ python3.6 -m venv ../virtualenv
- elspeth@server:$ ls ../virtualenv/bin
- activate activate.fish easy_install-3.6 pip3 python
- activate.csh easy_install pip pip3.6 python3
如果想激活这个虚拟环境,就执行 source ../virtualenvbinactivate
,但是无须这么做。其实,可以直接调用虚拟环境 bin 目录中的可执行文件,运行相应版本的 Python、pip 等。稍后就将这么做。
为了把所需的依赖安装到虚拟环境中,使用虚拟环境中的 pip:
- elspeth@server:$ ../virtualenvbinpip install -r requirements.txt
- Downloading/unpacking Django==1.11 (from -r requirements.txt (line 1))
- [...]
- Successfully installed Django
为了运行虚拟环境中的 Python,使用虚拟环境中的 python 二进制文件:
- elspeth@server:$ ../virtualenvbinpython manage.py runserver
- Validating models...
- 0 errors found
- [...]
如果防火墙配置得当,你现在甚至可以手动访问网站。你要执行
runserver 0.0.0.0:8000
,监听外网和内网 IP 地址,然后访问 http://your.domain.com:8000。
看起来能正常运行。按 Ctrl-C 键停止服务器。
又取得了进展!现在,我们能把代码推送到服务器上(git push
),也能从服务器上拉取代码(git pull
)。而且我们搭建了一个与本地一致的虚拟环境,还有一个文件(requirements.txt)同步依赖。
接下来将配置 Nginx Web 服务器,让它与 Django 通信,并把网站放到标准的 80 端口上。
9.5.3 简单配置Nginx
下面创建一个 Nginx 配置文件,把过渡网站收到的请求交给 Django 处理。如下是一个极简的配置。
server: etcnginx/sites-available/superlists-staging.ottg.eu
server {
listen 80;
server_name superlists-staging.ottg.eu;
location {
proxy_pass http:/localhost:8000;
}
}
这个配置只监听过渡网站的域名,而且会把所有请求“代理”到本地 8000 端口,等待 Django 处理请求后得到的响应。
我把这个配置保存为 superlists-staging.ottg.eu 文件,放在 etcnginx/sites-available 文件夹里。
不知道在服务器中如何编辑文件吗?服务器中都有 vi,我建议你之后学习一下如何使用这个工具。此外,也可以使用对初学者相对友好的
nano
。注意,还得使用sudo
,因为这个文件在系统文件夹中。
然后创建一个符号链接,把这个文件加入启用的网站列表中:
- elspeth@server:$ echo $SITENAME # 检查在这个shell会话中是否还能使用这个变量获取网站名
- superlists-staging.ottg.eu
- elspeth@server:$ sudo ln -s ../sites-available/$SITENAME etcnginx/sitesenabled/$SITENAME
- elspeth@server:$ ls -l etcnginx/sitesenabled # 确认符号链接是否在那里
在 Debian 和 Ubuntu 中,这是保存 Nginx 配置的推荐做法——把真正的配置文件放在 sites-available 文件夹中,然后在 sitesenabled 文件夹中创建一个符号链接。这么做便于切换网站的在线状态。
或许我们还可以把默认的“Welcome to nginx”页面删除,避免混淆:
- elspeth@server:$ sudo rm etcnginx/sitesenabled/default
现在测试一下配置:
- elspeth@server:$ sudo systemctl reload nginx
- elspeth@server:$ ../virtualenvbinpython manage.py runserver
我还要编辑 etcnginx/nginx.conf 文件,把
server_names_hash_bucket_size 64;
这行的注释去掉,这样才能使用我的长域名。你或许不会遇到这个问题。执行reload
命令时,如果配置文件有问题,Nginx 会提醒你。
快速进行视觉确认——网站运行起来了(如图 9-3)!
图 9-3:过渡网站运行起来了!
如果 Nginx 出现异常,执行
sudo nginx -t
命令试试。这个命令的作用是测试配置;如果发现配置文件中有问题,它会提醒你。
我们来看功能测试的结果如何:
- $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- [...]
- AssertionError: 0.0 != 512 within 3 delta
尝试提交新待办事项时测试失败了,因为还没设置数据库。运行测试时你可能注意到了 Django 的黄色报错页(如图 9-4 所示,手动访问网站也能看到),页面中显示的信息和测试失败消息差不多。
图 9-4:数据库还无法使用
测试避免让我们陷入可能出现的窘境之中。访问网站首页时看起来很正常,如果此时草率地认为工作结束了,那个扰人的 Django 报错页就会被网站的首批用户发现。好吧,这么说可能夸大了影响,说不定我们自己已经发现了,可是如果网站越来越大、越来越复杂怎么办?你不可能确认每项功能,但测试能。
9.5.4 使用迁移创建数据库
执行 migrate
命令时,可以指定 --noinput
参数,禁止两次询问“你确定吗”:
- elspeth@server:$ ../virtualenvbinpython manage.py migrate --noinput
- Creating tables ...
- [...]
- elspeth@server:$ ls ../database/
- db.sqlite3
- elspeth@server:$ ../virtualenvbinpython manage.py runserver
再运行功能测试试试:
$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
[...]
...
---------------------------------------------------------------------
Ran 3 tests in 10.718s
OK
看到网站运行起来的感觉太棒了!继续阅读下一节之前,或许我们可以犒赏自己,喝杯茶休息一下——这是你应得的奖励。
如果看到“502 - Bad Gateway”错误,可能是因为执行
migrate
命令之后忘记使用manage.py runserver
重启开发服务器。
下述框注中还有一些调试技巧。
服务器调试技巧
部署是个棘手活儿。如果遇到问题,可以使用以下技巧找出原因。
- 我知道你已经检查过了,不过还是再检查一遍各个文件,看它们的位置和内容是否正确。哪怕只错一个字符,也可能导致重大问题。
- 查看 Nginx 的错误日志,存储在 varlog/nginx/error.log 中。
- 可以使用
-t
标志检查 Nginx 的配置:nginx -t
。- 确保浏览器没有缓存过期的响应。按下 Ctrl 键的同时点击刷新按钮,或者打开一个新的隐私窗口。
- 最后,可以使用
sudo reboot
彻底重启试试。遇到无从下手的问题时,我有时就是这样解决的。如果真的遇到无法解决的问题,还可以推倒重来。第二次肯定顺手得多……
9.6 手动部署大功告成
终于结束了。经过一番努力,我们让网站运行起来了,至少基本上可用了。但是,在生产环境真的不能使用 Django 开发服务器,而且不能靠手动执行 runserver
命令启动服务器。下一章将改进部署过程,让它更适用于生产环境。
测试驱动服务器配置和部署
测试能降低部署过程的不确定性
对开发者来说,管理服务器始终充满“乐趣”,我的意思是,这个过程充满不确定性和意外。我写这一章的目的是告诉你,功能测试组件能降低这个过程的不确定性。
常见的痛点——数据库、静态文件、依赖、自定义设置
数据库配置、静态文件、软件依赖和自定义设置在开发环境和生产环境之间有所不同,部署时要格外留意。自己部署时,一定要审慎处理这些事物。
有测试护航,可以放心试验
改动服务器配置后,可以运行测试组件,确保一切依然正常。有测试的保护,我们可以放心试验,少一分担忧(具体内容参见下一章)。
第 10 章 为部署到生产环境做好准备
本章将做些修改,让我们的网站更适应生产环境的配置。每次修改都将通过测试确认功能是否依然可用。
前面的部署过程有什么问题呢?问题在于,生产环境不能使用 Django 开发服务器,而且没有考虑到“真实的”负载。我们将换用 Gunicorn 运行 Django 代码,并且使用 Nginx 伺服静态文件。
目前的 settings.py 把 DEBUG
设为 True
,我极不推荐在生产环境这么做(我们可不想在网站出错时让用户看到供调试的调用跟踪)。安全起见,我们还将设定 ALLOWED_HOSTS
。
我们希望网站在服务器重启后自动启动。为此,我们将编写一个 Systemd 配置文件。
最后,把网站绑定到 8000 端口会导致无法在服务器中运行多个网站,因此将换用“unix 套接字”连通 Nginx 和 Django。
10.1 换用Gunicorn
知道为什么 Django 的吉祥物是一匹小马吗? Django 提供了很多功能,包括 ORM、各种中间件、网站后台等。“除了小马之外你还想要什么呢?”我想,既然你已经有一匹小马了,或许你还想要一头“绿色独角兽”(Green Unicorn),即 Gunicorn。
- elspeth@server:$ ../virtualenvbinpip install gunicorn
Gunicorn 需要知道 WSGI(Web Server Gateway Interface,Web 服务器网关接口)服务器的路径。这个路径往往可以使用一个名为 application
的函数获取。Django 在文件 superlists/ wsgi.py 中提供了这个函数:
- elspeth@server:$ ../virtualenvbingunicorn superlists.wsgi:application
- 2013-05-27 16:22:01 [10592] [INFO] Starting gunicorn 0.19.6
- 2013-05-27 16:22:01 [10592] [INFO] Listening at: http://127.0.0.1:8000 (10592)
- [...]
如果现在访问网站,会发现所有样式都失效了,如图 10-1 所示。
图 10-1:样式失效
如果运行功能测试,会看到的确出问题了。添加待办事项的测试能顺利通过,但布局和样式的测试失败了。测试做得不错!
- $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
- [...]
- AssertionError: 125.0 != 512 within 3 delta
- FAILED (failures=1)
样式失效的原因是,Django 开发服务器会自动伺服静态文件,但 Gunicorn 不会。现在配置 Nginx,让它代为伺服静态文件。
我们前进了一步,又后退了一步,不过有测试在辅助我们。继续前进!
10.2 让Nginx伺服静态文件
首先,执行 collectstatic
命令,把所有静态文件复制到一个 Nginx 能找到的文件夹中:
- elspeth@server:$ ../virtualenvbinpython manage.py collectstatic --noinput
- elspeth@server:$ ls ..static
- base.css bootstrap
下面配置 Nginx,让它伺服静态文件:
server {
listen 80;
server_name superlists-staging.ottg.eu;
location /static {
alias homeelspeth/sites/superlists-staging.ottg.eu/static;
}
location {
proxy_pass http:/localhost:8000;
}
}
然后重启 Nginx 和 Gunicorn:
- elspeth@server:$ sudo systemctl reload nginx
- elspeth@server:$ ../virtualenvbingunicorn superlists.wsgi:application
如果再次访问网站,会看到外观正常多了。可以再次运行功能测试确认:
- $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
- [...]
- ...
- ---------------------------------------------------------------------
- Ran 3 tests in 10.718s
- OK
搞定!
10.3 换用Unix套接字
如果想要同时伺服过渡网站和线上网站,这两个网站就不能共用 8000 端口。可以为不同网站分配不同端口,但这么做有点儿随意,而且很容易出错,万一在线上网站的端口上启动过渡服务器(或者反过来)怎么办。
更好的方法是使用 Unix 域套接字。域套接字类似于硬盘中的文件,不过还可以用来处理 Nginx 和 Gunicorn 之间的通信。要把套接字保存在文件夹 /tmp 中。下面修改 Nginx 的代理设置。
server: etcnginx/sites-available/superlists-staging.ottg.eu
[...]
location {
proxy_set_header Host $host;
proxy_pass http:/unix:tmpsuperlists-staging.ottg.eu.socket;
}
}
proxy_set_header
的作用是让 Gunicorn 和 Django 知道它们运行在哪个域名下。ALLOWED_HOSTS
安全功能需要这个设置,稍后会启用这个功能。
现在重启 Gunicorn,不过这一次告诉它监听套接字,而不是默认的端口:
- elspeth@server:$ sudo systemctl reload nginx
- elspeth@server:$ ../virtualenvbingunicorn --bind \
- unix:tmpsuperlists-staging.ottg.eu.socket superlists.wsgi:application
还要再次运行功能测试,确保所有测试仍能通过:
- $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
- [...]
- OK
还差几步才能完成部署。
10.4 把DEBUG
设为False
,设置ALLOWED_HOSTS
在自己的服务器中开启调试模式有利于排查问题,但显示满页的调用跟踪不安全。
在 settings.py 的顶部有 DEBUG
设置项。如果把它设为 False
,还需要设置另一个选项,ALLOWED_HOSTS
。这个设置在 Django 1.5 中添加,目的是提高安全性。不过,在默认的 settings.py 中没有为这个功能提供有帮助的注释。那就自己添加这个选项吧,在服务器中按照下面的方式修改 settings.py。
server: superlists/settings.py
# 安全警告:别在生产环境中开启调试模式!
DEBUG = False
TEMPLATE_DEBUG = DEBUG
# DEBUG=False时需要这项设置
ALLOWED_HOSTS = ['superlists-staging.ottg.eu']
[...]
然后重启 Gunicorn,再运行功能测试,确保一切正常。
在服务器中别提交这些改动。现在这只是为了让网站正常运行做的小调整,不是需要纳入仓库的改动。一般来说,简单起见,我只会在本地电脑中把改动提交到 Git 仓库中。如果需要把代码同步到服务器中,再使用
git push
和git pull
。
再次运行测试,确认一切正常:
- $ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
- [...]
- OK
很好。
10.5 使用Systemd确保引导时启动Gunicorn
部署的最后一步是确保服务器引导时自动启动 Gunicorn,如果 Gunicorn 崩溃了还要自动重启。在 Ubuntu 中,可以使用 Systemd 实现这个功能。
server: etcsystemd/system/gunicorn-superlists-staging.ottg.eu.service
[Unit]
Description=Gunicorn server for superlists-staging.ottg.eu
[Service]
Restart=on-failure ➊
User=elspeth ➋
WorkingDirectory=homeelspeth/sites/superlists-staging.ottg.eu/source ➌
ExecStart=homeelspeth/sites/superlists-staging.ottg.eu/virtualenvbingunicorn \
--bind unix:tmpsuperlists-staging.ottg.eu.socket \
superlists.wsgi:application ➍
[Install]
WantedBy=multi-user.target ❺
Systemd 的配置很简单(如果曾经编写过 init.d 脚本,会觉得更简单),而且一目了然。
❶ Restart=on-failure
指明在崩溃时自动重启进程。
❷ User=elspeth
指明以“elspeth”用户的身份运行进程。
❸ WorkingDirectory
设定当前工作目录。
❹ ExecStart
是要执行的进程。为了提高可读性,我们使用行接续符 \
把整个命令分成多行,不过也可以写成一行。
❺ [Install]
区中的 WantedBy
告诉 Systemd,我们想在引导时启动这个服务。
Systemd 脚本保存在 etcsystemd/system 中,而且文件名必须以 .service 结尾。
下面告诉 Systemd,使用 systemctl
命令启动 Gunicorn:
- # 必须执行这个命令,让Systemd加载新的配置文件
- elspeth@server:$ sudo systemctl daemon-reload
- # 这个命令让Systemd在引导时加载服务
- elspeth@server:$ sudo systemctl enable gunicorn-superlists-staging.ottg.eu
- # 这个命令启动服务
- elspeth@server:$ sudo systemctl start gunicorn-superlists-staging.ottg.eu
(顺便说一下,你会发现 systemctl
命令支持按制表符补全,甚至可以补全服务名称。)
现在可以再次运行功能测试,确保一切仍能正常运行。你甚至还可以重启服务器,查看网站能否自动重新运行。
更多调试技巧
- 执行
sudo journalctl -u gunicorn-superlists-staging.ottg.eu
命令查看 Systemd 的日志。- 可以让 Systemd 检查服务配置是否有效:
systemd-analyze verify pathto/my.service
。- 改动后记得重启相关服务。
- 修改 Systemd 配置文件后,如果想查看改动的效果,要在执行
systemctl restart
之前执行daemon-reload
命令。
保存改动:把Gunicorn添加到requirements.txt
回到本地仓库,应该把 Gunicorn 添加到虚拟环境所需的包列表中:
- $ pip install gunicorn
- $ pip freeze | grep gunicorn >> requirements.txt
- $ git commit -am "Add gunicorn to virtualenv requirements"
- $ git push
写作本书时,在 Windows 中使用
pip
能顺利安装 Gunicorn,但 Gunicorn 无法正常使用。幸好我们只在服务器中使用 Gunicorn,因此这不是问题。不过,对 Windows 的支持正在讨论中。
10.6 考虑自动化
总结一下配置和部署的过程。
- 配置
(1) 假设有用户账户和家目录。
(2) add-apt-repository ppa:fkrull/deadsnakes
。
(3) apt-get install nginx git python3.6 pxthon3.6-venv
。
(4) 添加 Nginx 虚拟主机配置。
(5) 添加 Upstart 任务,自动启动 Gunicorn。
- 部署
(1) 在~/sites 中创建目录结构。
(2) 拉取源码,保存到 source 文件夹中。
(3) 启用 ../virtualenv 中的虚拟环境。
(4) pip install -r requirements.txt
。
(5) 执行 manage.py migrate
,创建数据库。
(6) 执行 collectstatic
命令,收集静态文件。
(7) 在 settings.py 中设置 DEBUG = False 和 ALLOWED_HOSTS
。
(8) 重启 Gunicorn。
(9) 运行功能测试,确保一切正常。
假设现在不用完全自动化配置过程,应该怎么保存现阶段取得的结果呢?我说应该把 Nginx 和 Systemd 配置文件保存起来,便于以后重用。下面把这两个配置文件保存到仓库中一个新建的子文件夹中。
保存配置文件的模板
首先,创建这个子文件夹:
- $ mkdir deploy_tools
下面是 Nginx 配置的通用模板:
deploy_tools/nginx.template.conf
server {
listen 80;
server_name SITENAME;
location /static {
alias homeelspeth/sites/SITENAME/static;
}
location {
proxy_set_header Host $host;
proxy_pass http:/unix:tmpSITENAME.socket;
}
}
下面是 Gunicorn Systemd 服务的模板:
deploy_tools/gunicorn-systemd.template.service
[Unit]
Description=Gunicorn server for SITENAME
[Service]
Restart=on-failure
User=elspeth
WorkingDirectory=homeelspeth/sites/SITENAME/source
ExecStart=homeelspeth/sites/SITENAME/virtualenvbingunicorn \
--bind unix:tmpSITENAME.socket \
superlists.wsgi:application
[Install]
WantedBy=multi-user.target
再使用这两个文件配置新网站就容易了,查找替换 SITENAME
即可。
其他步骤做些笔记就行。为什么不在仓库中建个文件保存说明呢?
deploy_tools/provisioning_notes.md
配置新网站
配置新网站
需要的包:
nginx
Python 3.6
virtualenv + pip
Git
以Ubuntu为例:
sudo add-apt-repository ppa:fkrull/deadsnakes
sudo apt-get install nginx git python36 python3.6-venv
Nginx虚拟主机
参考nginx.template.conf
把SITENAME替换成所需的域名,例如staging.my-domain.com
Systemd服务
参考gunicorn-upstart.template.conf
把SITENAME替换成所需的域名,例如staging.my-domain.com
文件夹结构:
假设有用户账户,家目录为homeusername
homeusername
└── sites
└── SITENAME
├── database
├── source
├── static
└── virtualenv
然后提交上述改动:
- $ git add deploy_tools
- $ git status # 看到3个新文件
- $ git commit -m "Notes and template config files for provisioning"
现在,源码的目录结构如下所示:
.
├── deploy_tools
│ ├── gunicorn-systemd.template.service
│ ├── nginx.template.conf
│ └── provisioning_notes.md
├── functional_tests
│ ├── [...]
├── lists
│ ├── __init__.py
│ ├── models.py
│ ├── [...]
│ ├── static
│ │ ├── base.css
│ │ └── bootstrap
│ │ ├── [...]
│ ├── templates
│ │ ├── base.html
│ │ ├── [...]
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── manage.py
├── requirements.txt
└── superlists
├── [...]
10.7 保存进度
在过渡服务器中运行功能测试,能让我们相信网站确实能正常运行。但大多数情况下,你并不想在真正的服务器中运行功能测试。为了不让我们的劳动付之东流,并且保证生产服务器和过渡服务器一样能正常运行,要让部署的过程可重复执行。
为此,我们需要自动化。这是下一章的话题。
为部署生产服务器做好准备
为生产服务器环境做准备时要考虑以下几点。
不要在生产环境使用 Django 开发服务器
Django 更适合在 Gunicorn 或 uWSGI 等中运行,因为它们支持运行多个职程(worker)。
不要使用 Django 伺服静态文件
没必要用 Python 进程处理伺服静态文件这样的简单任务。可以交给 Nginx 去做,当然其他 Web 服务器也能做到,比如 Apache 或 uWSGI。
检查 settings.py 中只针对开发环境的设置
本章提到了
DEBUG=True
和ALLOWED_HOSTS
,不过可能还有其他设置要注意(让服务器发送电子邮件时还会涉及几个)。安全性
本书篇幅有限,无法深入讨论服务器安全性。但我要提醒你,若想自己运行服务器,一定要掌握一些安全知识。(有些人选择使用 PaaS 的一个原因就是无须过多担心安全问题。)如果你不知从何着手,可以阅读这篇文章:“My first 5 minutes on a server”。强烈建议你安装 fail2ban,然后查看它的日志文件。你会惊奇地发现,尝试暴力登录 SSH 的活动多得不得了。互联网可不是什么安全之所!
第 11 章 使用Fabric自动部署
“自动化,自动化,自动化。”
——Cay Horstman
手动部署过渡服务器的意义通过自动部署才能体现出来。部署的过程能重复执行,我们才能确信部署到生产环境时不会出错。
使用 Fabric 可以在服务器中自动执行命令。fabric 3 是针对 Python 3 的派生版本:
- $ pip install fabric3
安装 fabric3 的过程中,只要最后提示“Successfully installed…”,就可以放心忽略“failed building wheel”错误。
Fabric 的使用方法一般是创建一个名为 fabfile.py 的文件,在这个文件中定义一个或多个函数,然后使用命令行工具 fab
调用,就像这样:
fab function_name:host=SERVER_ADDRESS
这个命令会调用名为 function_name
的函数,并传入要连接的服务器地址 SERVER_ADDRESS
。fab
命令还有很多其他参数,可以指定用户名和密码等,详情可执行 fab --help
命令查阅。
11.1 分析一个Fabric部署脚本
Fabric 的用法最好通过一个实例讲解。我事先写好了一个脚本 1,自动执行前一章用到的所有部署步骤。在这个脚本中,主函数是 deploy
,我们在命令行中要调用的就是这个函数。deploy
函数还会调用几个辅助函数,我们会在过程中逐一讲解。
1BBC 的儿童节目《蓝彼得》中经常说这句话,因为这是个直播节目,为了节省时间,往往要事先做好所需的道具。这句话的原文是“Here's one I made earlier”。——译者注
deploy_tools/fabfile.py (ch09l001)
from fabric.contrib.files import append, exists, sed
from fabric.api import env, local, run
import random
REPO_URL = 'https://github.com/hjwp/bookexample.git' ➊
def deploy():
site_folder = f'home{env.user}/sites/{env.host}' ➋➌
source_folder = site_folder + '/source'
createdirectory_structure_if_necessary(site_folder)
getlatest_source(source_folder)
updatesettings(source_folder, env.host) ➋
updatevirtualenv(source_folder)
updatestatic_files(source_folder)
updatedatabase(source_folder)
❶ 要把常量 REPO_URL
的值改成代码分享网站中你仓库的 URL。
❷ env.host
的值是在命令行中指定的服务器地址,例如 superlists.ottg.eu。
❸ env.user
的值是登录服务器时使用的用户名。
希望辅助函数的名字能表明各自的作用。理论上 fabfile.py 中的每个函数都能在命令行中调用,所以我使用了一种约定,凡是以下划线开头的函数都不是 fabfile.py 的“公开 API”。这些辅助函数按照执行的顺序排列。
11.1.1 分析一个Fabric部署脚本
创建目录结构的方法如下,即便某个文件夹已经存在也不会报错:
deploy_tools/fabfile.py (ch09l002)
def createdirectory_structure_if_necessary(site_folder):
for subfolder in ('database', 'static', 'virtualenv', 'source'):
run(f'mkdir -p {site_folder}/{subfolder}') ➊➋
➊ run
是最常用的 Fabric 函数,作用是在服务器中执行指定的 shell 命令。本章将用 run
命令替代前两章手动执行的多个命令。
➋ mkdir -p
是 mkdir
的一个有用变种,它有两个优势:其一,深入多个文件夹层级创建目录;其二,只在必要时创建目录。所以,mkdir -p tmpfoo/bar
除了创建目录 bar 之外,如果需要,还会创建父级目录 foo。而且,如果目录 bar 已经存在,也不会报错。2
2如果你想知道构建路径为什么使用 f
字符串而不用前面用过的 os.path.join
,我告诉你,因为在 Windows 中运行这个脚本,path.join
会使用反斜线,但在服务器中却要使用斜线。这是一个常见陷阱。
11.1.2 使用Git拉取源码
接下来,我们想像前面那样使用 git pull
把最新的源码下载到服务器中:
deploy_tools/fabfile.py (ch09l003)
def getlatest_source(source_folder):
if exists(source_folder + '/.git'): ➊
run(f'cd {source_folder} && git fetch') ➋➌
else:
run(f'git clone {REPO_URL} {source_folder}') ➍
current_commit = local("git log -n 1 --format=%H", capture=True) ❺
run(f'cd {source_folder} && git reset --hard {current_commit}') ❻
❶ exists
检查服务器中是否有指定的文件夹或文件。我们指定的是隐藏文件夹 .git,检查仓库是否已经克隆到文件夹中。
❷ 很多命令都以 cd
开头,其目的是设定当前工作目录。Fabric 没有状态记忆,所以下次运行 run
命令时不知道在哪个目录中。3
3Fabric 本身也提供了 cd
函数,但我觉得本章要多次用到这个函数,太啰唆。
❸ 在现有仓库中执行 git fetch
命令的作用是从网络中拉取最新提交(与 git pull
类似,但是不会立即更新线上源码)。
❹ 如果仓库不存在,就执行 git clone
命令克隆一份全新的源码。
❺ Fabric 中的 local
函数在本地电脑中执行命令,这个函数其实是对 subprocess.Popen
的再包装,不过用起来十分方便。我们捕获 git log
命令的输出,获取本地仓库中当前提交的 ID。这么做的结果是,服务器中代码将和本地检出的代码版本一致(前提是已经把代码推送到服务器)。
❻ 执行 git reset --hard
命令,切换到指定的提交。这个命令会撤销在服务器中对代码仓库所做的任何改动。
这个函数的最终结果是,全新部署时执行 git clone
,已有代码时执行 git fetch
和 git reset --hard
。手动部署时,我们执行的是等效的 git pull
,但是使用 reset --hard
能强制覆盖本地改动。
为了让这个脚本可用,你要执行
git push
命令把本地仓库推送到代码分享网站,这样服务器才能拉取仓库,再执行git reset
命令。如果你遇到Could not parse object
错误,可以执行git push
命令试试。
11.1.3 更新settings.py
然后更新配置文件,设置 ALLOWED_HOSTS
和 DEBUG
,还要创建一个密钥:
deploy_tools/fabfile.py (ch09l004)
def updatesettings(source_folder, site_name):
settings_path = source_folder + 'superlistssettings.py'
sed(settings_path, "DEBUG = True", "DEBUG = False") ➊
sed(settings_path,
'ALLOWED_HOSTS =.+$',
f'ALLOWED_HOSTS = ["{site_name}"]' ➋
)
secret_key_file = source_folder + 'superlistssecret_key.py'
if not exists(secret_key_file): ➌
chars = 'abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)'
key = ''.join(random.SystemRandom().choice(chars) for in range(50))
append(secretkey_file, f'SECRET_KEY = "{key}"')
append(settings_path, '\nfrom .secret_key import SECRET_KEY') ➍❺
❶ Fabric 提供的 sed
函数的作用是在文件中替换字符串。这里我们把 DEBUG
的值由 True
改成 False
。
❷ 这里使用 sed
调整 ALLOWED_HOSTS
的值,使用正则表达式匹配正确的代码行。
❸ Django 有几处加密操作要使用 SECRET_KEY
:cookie 和 CSRF 保护。在服务器中和源码仓库中使用不同的密钥是个好习惯,因为仓库中的代码能被所有人看到。如果还没有密钥,这段代码会生成一个新密钥,然后写入密钥文件。有密钥后,每次部署都要使用相同的密钥。更多信息参见 Django 文档。
❹ append
的作用是在文件末尾添加一行内容。(这个函数很聪明,如果要添加的行已经存在,就不会再次添加;但如果文件末尾不是一个空行,它却不能自动添加一个空行。因此我们加上了 \n
。)
❺ 我使用的是相对导入(relative import,使用 from .secret_key
而不是 from secret_key
),目的是确保从本地而不是从 sys.path
中其他位置的模块中导入。下一章我会更深入地介绍相对导入。
以上是修改服务器配置文件的一种方法,另一种常用的方法是使用环境变量(详情请参见第 21 章)。你可以根据个人喜好选择。
11.1.4 更新虚拟环境
接下来创建或更新虚拟环境:
deploy_tools/fabfile.py (ch09l005)
def updatevirtualenv(source_folder):
virtualenv_folder = source_folder + '..virtualenv'
if not exists(virtualenv_folder + 'binpip'): ➊
run(f'python3.6 -m venv {virtualenv_folder}')
run(f'{virtualenv_folder}binpip install -r {source_folder}/requirements.txt') ➋
❶ 在 virtualenv 文件夹中查找可执行文件 pip
,以此检查虚拟环境是否存在。
❷ 然后和之前一样,执行 pip install -r
命令。
更新静态文件只需要一个命令:
deploy_tools/fabfile.py (ch09l006)
def updatestatic_files(source_folder):
run(
f'cd {source_folder}' ➊
' && ../virtualenvbinpython manage.py collectstatic --noinput' ➋
)
❶ 在 Python 中,可以像这样把一行长字符串分为多行,最终拼接为一个字符串。如果真正想要的是字符串列表,但是忘了逗号,就经常会出问题。
❷ 如果需要执行 Django 的 manage.py
命令,就要指定虚拟环境中二进制文件夹,确保使用的是虚拟环境中的 Django 版本,而不是系统中的版本。
11.1.5 需要时迁移数据库
最后,执行 manage.py migrate
命令更新数据库:
deploy_tools/fabfile.py (ch09l007)
def updatedatabase(source_folder):
run(
f'cd {source_folder}'
' && ../virtualenvbinpython manage.py migrate --noinput'
)
指定 --noinput
选项的目的是不让 Fabric 难以处理的交互式确认(回答“yes”或“no”)出现。
到此结束!虽然要理解很多新东西,但这是值得的,因为我们把整个手动过程变成自动过程了。而且通过一些逻辑处理,我们既能部署全新的服务器,也能更新现有的服务器。如果你喜欢源自拉丁语的词,可以称这个过程是幂等的(idempotent4),即不管执行一次还是执行多次,效果是一样的。
4“幂等”的英文 idempotent,拉丁语词根是 idem。——译者注
11.2 试用部署脚本
下面在现有的过渡网站中试一下这个脚本,看它是如何更新现有网站的:
- $ cd deploy_tools
- $ fab deploy:host=elspeth@superlists-staging.ottg.eu
- [superlists-staging.ottg.eu] Executing task 'deploy'
- [superlists-staging.ottg.eu] run: mkdir -p homeelspeth/sites/superlists-stagin
- [superlists-staging.ottg.eu] run: mkdir -p homeelspeth/sites/superlists-stagin
- [superlists-staging.ottg.eu] run: mkdir -p homeelspeth/sites/superlists-stagin
- [superlists-staging.ottg.eu] run: mkdir -p homeelspeth/sites/superlists-stagin
- [superlists-staging.ottg.eu] run: mkdir -p homeelspeth/sites/superlists-stagin
- [superlists-staging.ottg.eu] run: cd homeelspeth/sites/superlists-staging.ottg
- [localhost] local: git log -n 1 --format=%H
- [superlists-staging.ottg.eu] run: cd homeelspeth/sites/superlists-staging.ottg
- [superlists-staging.ottg.eu] out: HEAD is now at 85a6c87 Add a fabfile for autom
- [superlists-staging.ottg.eu] out:
- [superlists-staging.ottg.eu] run: sed -i.bak -r -e 's/DEBUG = True/DEBUG = False
- [superlists-staging.ottg.eu] run: echo 'ALLOWED_HOSTS = ["superlists-staging.ott
- [superlists-staging.ottg.eu] run: echo 'SECRET_KEY = '\\''4p2u8fi6)bltep(6nd_3tt
- [superlists-staging.ottg.eu] run: echo 'from .secret_key import SECRET_KEY' >> "
- [superlists-staging.ottg.eu] run: homeelspeth/sites/superlists-staging.ottg.eu
- [superlists-staging.ottg.eu] out: Requirement already satisfied (use --upgrade t
- [superlists-staging.ottg.eu] out: Requirement already satisfied (use --upgrade t
- [superlists-staging.ottg.eu] out: Cleaning up...
- [superlists-staging.ottg.eu] out:
- [superlists-staging.ottg.eu] run: cd homeelspeth/sites/superlists-staging.ottg
- [superlists-staging.ottg.eu] out:
- [superlists-staging.ottg.eu] out: 0 static files copied, 11 unmodified.
- [superlists-staging.ottg.eu] out:
- [superlists-staging.ottg.eu] run: cd homeelspeth/sites/superlists-staging.ottg
- [superlists-staging.ottg.eu] out: Creating tables ...
- [superlists-staging.ottg.eu] out: Installing custom SQL ...
- [superlists-staging.ottg.eu] out: Installing indexes ...
- [superlists-staging.ottg.eu] out: Installed 0 object(s) from 0 fixture(s)
- [superlists-staging.ottg.eu] out:
- Done.
- Disconnecting from superlists-staging.ottg.eu... done.
太棒了。我喜欢让电脑成页地显示这种输出(事实上,我完全无法阻止自己制造 20 世纪 70 年代的电脑发出的“啵呃 - 啵呃 - 啵呃”声音,就像《异形》中的电脑 Mother 一样 5)。如果仔细看这些输出,会发现脚本在执行我们的命令:虽然目录结构已经建好,但 mkdir -p
命令还是能顺利执行;然后执行 git pull
命令,拉取我们刚刚提交的几次改动;sed
和 echo >>
修改 settings.py 文件;然后顺利执行完 pip install -r requirements.txt
命令,注意,现有的虚拟环境中已经安装了全部所需的包;collectstatic
命令发现静态文件也收集好了;最后,执行 migrate
命令。这个过程完全没障碍。
5Mother 是电影《异形》系列中诺史莫号的中央控制系统。——译者注
配置 Fabric
如果使用 SSH 密钥登录,密钥存储在默认的位置,而且本地电脑和服务器使用相同的用户名,那么无须配置即可直接使用 Fabric。如果不满足这几个条件,就要配置用户名、SSH 密钥的位置或密码等,才能让
fab
执行命令。这几个信息可在命令行中传给 Fabric。更多信息可执行 $
fab --help
命令查看,或者阅读 Fabric 的文档。
11.2.1 部署到线上服务器
下面在线上服务器中试试这个脚本:
- $ fab deploy:host=elspeth@superlists.ottg.eu
- $ fab deploy --host=superlists.ottg.eu
- [superlists.ottg.eu] Executing task 'deploy'
- [superlists.ottg.eu] run: mkdir -p homeelspeth/sites/superlists.ottg.eu
- [superlists.ottg.eu] run: mkdir -p homeelspeth/sites/superlists.ottg.eu/databa
- [superlists.ottg.eu] run: mkdir -p homeelspeth/sites/superlists.ottg.eu/static
- [superlists.ottg.eu] run: mkdir -p homeelspeth/sites/superlists.ottg.eu/virtua
- [superlists.ottg.eu] run: mkdir -p homeelspeth/sites/superlists.ottg.eu/source
- [superlists.ottg.eu] run: git clone https://github.com/hjwp/bookexample.git /ho
- [superlists.ottg.eu] out: Cloning into 'homeelspeth/sites/superlists.ottg.eu/s
- [superlists.ottg.eu] out: remote: Counting objects: 3128, done.
- [superlists.ottg.eu] out: Receiving objects: 0% (1/3128)
- [...]
- [superlists.ottg.eu] out: Receiving objects: 100% (3128/3128), 2.60 MiB | 829 Ki
- [superlists.ottg.eu] out: Resolving deltas: 100% (1545/1545), done.
- [superlists.ottg.eu] out:
- [localhost] local: git log -n 1 --format=%H
- [superlists.ottg.eu] run: cd homeelspeth/sites/superlists.ottg.eu/source && gi
- [superlists.ottg.eu] out: HEAD is now at 6c8615b use a secret key file
- [superlists.ottg.eu] out:
- [superlists.ottg.eu] run: sed -i.bak -r -e 's/DEBUG = True/DEBUG = False/g' "$(e
- [superlists.ottg.eu] run: echo 'ALLOWED_HOSTS = ["superlists.ottg.eu"]' >> "$(ec
- [superlists.ottg.eu] run: echo 'SECRET_KEY = '\\''mqu(ffwid5vleol%ke^jil*x1mkj-4
- [superlists.ottg.eu] run: echo 'from .secret_key import SECRET_KEY' >> "$(echo /
- [superlists.ottg.eu] run: python3.6 -m venv homeelspeth/sites/superl
- [superlists.ottg.eu] out: Using interpreter usrbin/python3.6
- [superlists.ottg.eu] out: Using base prefix '/usr'
- [superlists.ottg.eu] out: New python executable in homeelspeth/sites/superlist
- [superlists.ottg.eu] out: Also creating executable in homeelspeth/sites/superl
- [superlists.ottg.eu] out: Installing Setuptools............................done.
- [superlists.ottg.eu] out: Installing Pip...................................done.
- [superlists.ottg.eu] out:
- [superlists.ottg.eu] run: homeelspeth/sites/superlists.ottg.eu/source..virtu
- [superlists.ottg.eu] out: Downloading/unpacking Django==1.11 (from -r homeel
- [superlists.ottg.eu] out: Downloading Django-1.11.tar.gz (8.0MB):
- [...]
- [superlists.ottg.eu] out: Downloading Django-1.11.tar.gz (8.0MB): 100% 8.0M
- [superlists.ottg.eu] out: Running setup.py egg_info for package Django
- [superlists.ottg.eu] out:
- [superlists.ottg.eu] out: warning: no previously-included files matching '__
- [superlists.ottg.eu] out: warning: no previously-included files matching '.
- [superlists.ottg.eu] out: Downloading/unpacking gunicorn==17.5 (from -r homeel
- [superlists.ottg.eu] out: Downloading gunicorn-17.5.tar.gz (367kB): 100% 367k
- [...]
- [superlists.ottg.eu] out: Downloading gunicorn-17.5.tar.gz (367kB): 367kB down
- [superlists.ottg.eu] out: Running setup.py egg_info for package gunicorn
- [superlists.ottg.eu] out:
- [superlists.ottg.eu] out: Installing collected packages: Django, gunicorn
- [superlists.ottg.eu] out: Running setup.py install for Django
- [superlists.ottg.eu] out: changing mode of build/scripts-3.3/django-admin.py
- [superlists.ottg.eu] out:
- [superlists.ottg.eu] out: warning: no previously-included files matching '__
- [superlists.ottg.eu] out: warning: no previously-included files matching '.
- [superlists.ottg.eu] out: changing mode of homeelspeth/sites/superlists.ot
- [superlists.ottg.eu] out: Running setup.py install for gunicorn
- [superlists.ottg.eu] out:
- [superlists.ottg.eu] out: Installing gunicorn_paster script to homeelspeth
- [superlists.ottg.eu] out: Installing gunicorn script to homeelspeth/sites/
- [superlists.ottg.eu] out: Installing gunicorn_django script to homeelspeth
- [superlists.ottg.eu] out: Successfully installed Django gunicorn
- [superlists.ottg.eu] out: Cleaning up...
- [superlists.ottg.eu] out:
- [superlists.ottg.eu] run: cd homeelspeth/sites/superlists.ottg.eu/source && ..
- [superlists.ottg.eu] out: Copying 'homeelspeth/sites/superlists.ottg.eu/source
- [superlists.ottg.eu] out: Copying 'homeelspeth/sites/superlists.ottg.eu/source
- [...]
- [superlists.ottg.eu] out: Copying 'homeelspeth/sites/superlists.ottg.eu/source
- [superlists.ottg.eu] out:
- [superlists.ottg.eu] out: 11 static files copied.
- [superlists.ottg.eu] out:
- [superlists.ottg.eu] run: cd homeelspeth/sites/superlists.ottg.eu/source && ..
- [superlists.ottg.eu] out: Creating tables ...
- [superlists.ottg.eu] out: Creating table auth_permission
- [...]
- [superlists.ottg.eu] out: Creating table lists_item
- [superlists.ottg.eu] out: Installing custom SQL ...
- [superlists.ottg.eu] out: Installing indexes ...
- [superlists.ottg.eu] out: Installed 0 object(s) from 0 fixture(s)
- [superlists.ottg.eu] out:
- Done.
- Disconnecting from superlists.ottg.eu... done.
啵呃 - 啵呃 - 啵呃。可以看出,这个脚本执行的路径有点不同。这一次,执行 git clone
命令克隆一个全新的仓库,而没有执行 git pull
;而且从零开始创建了一个新的虚拟环境,还安装了 pip 和 Django;collectstatic
命令这次真的创建了很多新文件;migrate
看起来也完成了任务。
11.2.2 使用sed
配置Nginx和Gunicorn
把网站放到生产环境之前还要做什么呢?根据配置笔记,还要使用模板文件创建 Nginx 虚拟主机和 Systemd 服务。使用 Unix 命令行工具完成这一操作怎么样?
- elspeth@server:$ sed "s/SITENAME/superlists.ottg.eu/g" \
- source/deploy_tools/nginx.template.conf \
- | sudo tee etcnginx/sites-available/superlists.ottg.eu
sed
(stream editor,流编辑器)的作用是编辑文本流。Fabric 中进行文本替换的函数也叫 sed
,这并不是巧合。这里,使用 s/replaceme/withthis/g
句法把字符串 SITENAME
替换成网站的地址。6 然后使用管道操作(|)把文本流传给一个有 root 权限的用户处理(sudo
),把传入的文本流写入一个文件,即 sites-available 文件夹中的一个虚拟主机配置文件。
6你可能在网上见过电脑达人使用奇怪的 s/change-this/to-this
的写法,这下你知道它的作用了吧。
然后使用一个符号链激活这个文件配置的虚拟主机:
- elspeth@server:$ sudo ln -s ../sites-available/superlists.ottg.eu \
- etcnginx/sitesenabled/superlists.ottg.eu
再使用 sed
编写 Systemd 服务:
- elspeth@server: sed "s/SITENAME/superlists.ottg.eu/g" \
- source/deploy_tools/gunicorn-systemd.template.service \
- | sudo tee etcsystemd/system/gunicorn-superlists.ottg.eu.service
最后,启动这两个服务:
- elspeth@server:$ sudo systemctl daemon-reload
- elspeth@server:$ sudo systemctl reload nginx
- elspeth@server:$ sudo systemctl enable gunicorn-superlists.ottg.eu
- elspeth@server:$ sudo systemctl start gunicorn-superlists.ottg.eu
现在访问网站,见图 11-1。运行起来了,太棒了!
图 11-1:啵呃 - 啵呃 - 啵呃……网站运行起来了
做得不错。可靠的 fabfile,赏你一块饼干。现在可以把它添加到仓库中了:
- $ git add deploy_tools/fabfile.py
- $ git commit -m "Add a fabfile for automated deploys"
11.3 使用Git标签标注发布状态
最后还要做些管理操作。为了保留历史标记,使用 Git 标签(tag)标注代码库的状态,指明服务器中当前使用的是哪个版本:
- $ git tag LIVE
- $ export TAG=$(date +DEPLOYED-%F/%H%M) # 生成一个时间戳
- $ echo $TAG # 会显示“DEPLOYED-”和时间戳
- $ git tag $TAG
- $ git push origin LIVE $TAG # 推送标签
现在,无论何时都能轻松查看当前代码库和服务器中的版本有何差异。这个操作在后面介绍数据库迁移的章节中也会用到。看一下提交历史中的标签:
- $ git log --graph --oneline --decorate
- [...]
总之,现在我们部署了一个线上网站。告诉你的朋友吧!如果他们不感兴趣,就告诉自己的妈妈!下一章我们要继续编程。
11.4 延伸阅读
部署没有唯一正确的方法,而且我无论如何也算不上资深专家。我试着把你领进门,但有很多事情可以使用不同的方法处理,还有很多很多的知识要学习。下面我列出了一些阅读材料,仅供参考。
- Hynek Schlawack 的文章“Solid Python Deployments for Everybody”。
- Dan Bravender 的文章“Git-based fabric deploys are awesome”。
- Dan Greenfield 和 Audrey Roy 合著的 Two Scoops of Django 中与部署相关的章节。
- Heroku 团队写的“The 12-factor App”。
如果想知道如何自动完成配置过程,以及如何使用 Fabric 的替代品 Ansible,请阅读附录 C。
自动部署
Fabric
Fabric 允许在 Python 脚本中编写可在服务器中执行的命令。这个工具很适合自动执行服务器管理任务。
幂等
如果部署脚本要在已经配置的服务器中运行,就要把它设计成既可在全新的服务器中运行,又能在已经配置的服务器中运行。
把配置文件纳入版本控制
一定不能只在服务器中保存一份配置文件副本。配置文件对应用非常重要,应该和其他文件一样纳入版本控制。
自动配置
最终,所有操作都要实现自动化,包括配置全新的服务器和安装所需的全部正确软件。配置的过程中会和主机供应商的 API 交互。
配置管理工具
Fabric 很灵活,但其逻辑还是基于脚本的。高级工具使用声明式的方法,用起来更方便。Ansible 和 Vagrant 都值得一试(参见附录 C),此外还有很多同类工具,例如 Chef、Puppet、Salt 和 Juju 等。
第 12 章 输入验证和测试的组织方式
接下来要实现的功能是输入验证。测试越写越多,慢慢地你会发现,把所有测试都写在 functional_tests.py 和 tests.py 中有诸多不便,因此我们将重新组织测试,将它们写入多个文件——这算得上是对测试本身的重构。
此外,我们还将定义一个通用的显式等待辅助方法。
12.1 针对验证的功能测试:避免提交空待办事项
我们的网站开始有用户了。我们注意到用户有时会犯错,把他们的清单弄得一团糟,例如不小心提交空的待办事项,或者在一个清单中输入两个相同的待办事项。计算机能帮助我们避免犯这种愚蠢的错误,看一下能否让网站提供这种帮助。
下面是一个功能测试的大纲:
functional_tests/tests.py (ch11l001)
def test_cannot_add_empty_list_items(self):
# 伊迪丝访问首页,不小心提交了一个空待办事项
# 输入框中没输入内容,她就按下了回车键
# 首页刷新了,显示一个错误消息
# 提示待办事项不能为空
# 她输入一些文字,然后再次提交,这次没问题了
# 她有点儿调皮,又提交了一个空待办事项
# 在清单页面她看到了一个类似的错误消息
# 输入文字之后就没问题了
self.fail('write me!')
测试写得很好,但功能测试文件变得有点儿臃肿,在继续之前,要把功能测试分成多个文件,每个文件中只放一个测试方法。
还记得吗?功能测试和“用户故事”联系紧密。如果使用问题跟踪程序等项目管理工具,你可能想让每个文件对应一个问题或工单(ticket),而且文件名中要包含工单的编号。如果你喜欢使用“功能”的概念考虑问题(一个功能可能包含多个用户故事),可以用一个文件对应一个功能,一个文件中只写一个测试类,每个用户故事使用多个测试方法实现。
还要编写一个测试基类,让所有测试类都继承这个基类。下面分步介绍分拆过程。
12.1.1 跳过测试
重构时最好能让整个测试组件都通过。刚才我们故意编写了一个失败测试,现在要使用 unittest
提供的修饰器 @skip
临时禁止执行这个测试方法:
functional_tests/tests.py (ch11l001-1)
from unittest import skip
[...]
@skip
def test_cannot_add_empty_list_items(self):
这个修饰器告诉测试运行程序,忽略这个测试。再次运行功能测试就会看到这么做起作用了,因为测试组件仍能通过:
- $ python manage.py test functional_tests
- [...]
- Ran 4 tests in 11.577s
- OK
跳过测试很危险,把改动提交到仓库之前记得删掉
@skip
修饰器。这就是逐行审查差异的目的。
别忘了“遇红 变绿 重构”中的“重构”
TDD 有时会被批评说得到的代码架构不好,因为开发者关注的是怎么让测试通过,没有停下来思考整个系统应该怎么设计。我觉得这么说有点不公平。
TDD 不是万能良药。你仍要花时间考虑好的设计。不过开发者经常会忘记“遇红 变绿 重构”中还有“重构”这一步。使用TDD,为了让测试通过,可以随意丢掉旧代码,但它也要求你在测试通过之后花点儿时间重构,改进设计。否则“技术债务”将高高筑起。
不过,重构的最佳方法往往不那么容易想到,可能等到写下代码之后的几天、几周甚至几个月,处理完全无关的事情时,突然灵光一闪才能想出来。在解决其他问题的途中,应该停下来去重构以前的代码吗?
这要视情况而定。比如像本章开始这种情况,我们还没有开始编写新代码,知道一切都能正常运行,所以可以跳过刚编写的功能测试(让测试全部通过),先重构。
在本章后面的内容中还会遇到需要重构的代码。届时,我们不能冒险在无法正常运行的应用中重构,可以在便签上做个记录,等测试组件能全部通过之后再重构。
12.1.2 把功能测试分拆到多个文件中
先把各个测试方法放在单独的类中,但仍然保存在同一个文件里:
functional_tests/tests.py (ch11l002)
class FunctionalTest(StaticLiveServerTestCase):
def setUp(self):
[...]
def tearDown(self):
[...]
def wait_for_row_in_list_table(self, row_text):
[...]
class NewVisitorTest(FunctionalTest):
def test_can_start_a_list_for_one_user(self):
[...]
def test_multiple_users_can_start_lists_at_different_urls(self):
[...]
class LayoutAndStylingTest(FunctionalTest):
def test_layout_and_styling(self):
[...]
class ItemValidationTest(FunctionalTest):
@skip
def test_cannot_add_empty_list_items(self):
[...]
然后运行功能测试,看是否仍能通过:
Ran 4 tests in 11.577s
OK
这么做可能有点劳动量,或许能找到一种步骤更少的方法。但是,正如我一直所说的,针对简单的情况练习步步为营的方法,以后遇到复杂的情况就能游刃有余。
现在分拆这个测试文件,一个类写入一个文件,而且还有一个文件用来保存所有测试类都继承的基类。要复制四份 test.py,重命名各个文件,然后删除各文件中不需要的代码:
- $ git mv functional_tests/tests.py functional_tests/base.py
- $ cp functional_tests/base.py functional_tests/test_simple_list_creation.py
- $ cp functional_tests/base.py functional_tests/test_layout_and_styling.py
- $ cp functional_tests/base.py functional_tests/test_list_item_validation.py
base.py 只需保留 FunctionalTest
类,其他代码全部删掉。留下基类中的辅助方法,因为觉得在新的功能测试中会用到:
functional_tests/base.py (ch11l003)
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
import time
MAX_WAIT = 10
class FunctionalTest(StaticLiveServerTestCase):
def setUp(self):
[...]
def tearDown(self):
[...]
def wait_for_row_in_list_table(self, row_text):
[...]
把辅助方法放在
FunctionalTest
基类中是避免功能测试代码重复的方法之一。第 25 章会用到“页面模式”(page pattern),与这种方法有关,但不用继承,用组合模式。
我们编写的第一个功能测试现在放在单独的文件中了,而且这个文件中只有一个类和一个测试方法:
functional_tests/test_simple_list_creation.py (ch11l004)
from .base import FunctionalTest
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
class NewVisitorTest(FunctionalTest):
def test_can_start_a_list_for_one_user(self):
[...]
def test_multiple_users_can_start_lists_at_different_urls(self):
[...]
我用到了相对导入(from .base
),有些人喜欢在 Django 应用中大量使用这种导入方式(例如,视图可能会使用 from .models import List
导入模型,而不用 from list.models
)。这其实是个人喜好问题,只有十分确定要导入的文件位置不会变化时,我才会选择使用相对导入。这里使用相对导入是因为,我确定所有测试文件都会和它们要继承的 base.py 放在一起。
针对布局和样式的功能测试现在也放在独立的文件和类中:
functional_tests/test_layout_and_styling.py (ch11l005)
from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest
class LayoutAndStylingTest(FunctionalTest):
[...]
刚编写的验证测试也放在单独的文件中了:
functional_tests/test_list_item_validation.py (ch11l006)
from selenium.webdriver.common.keys import Keys
from unittest import skip
from .base import FunctionalTest
class ItemValidationTest(FunctionalTest):
@skip
def test_cannot_add_empty_list_items(self):
[...]
可以再次执行 manage.py test functional_tests
命令,确保一切都正常,还要确认所有三个测试都运行了:
Ran 4 tests in 11.577s
OK
现在可以删掉 @skip
修饰器了:
functional_tests/test_list_item_validation.py (ch11l007)
class ItemValidationTest(FunctionalTest):
def test_cannot_add_empty_list_items(self):
[...]
12.1.3 运行单个测试文件
拆分之后有个附带的好处——可以运行单个测试文件,如下所示:
- $ python manage.py test functional_tests.test_list_item_validation
- [...]
- AssertionError: write me!
太好了,如果只关心其中一个测试,现在无须坐等所有功能测试都运行完就能看到结果了。不过,记得要不时运行所有功能测试,检查是否有回归。本书后面的内容会介绍如何把这项任务交给自动化持续集成循环完成。现在,先提交:
- $ git status
- $ git add functional_tests
- $ git commit -m "Moved Fts into their own individual files"
很好,我们把功能测试拆分到不同的文件中了。接下来,我们将着手编写功能测试。但在此之前,你可能猜到了,我们还需拆分单元测试文件。
12.2 功能测试新工具:通用显式等待辅助方法
现在开始实现本章开头编写的测试,至少先把前面的部分写好:
functional_tests/test_list_item_validation.py (ch11l008)
def test_cannot_add_empty_list_items(self):
# 伊迪丝访问首页,不小心提交了一个空待办事项
# 输入框中没输入内容,她就按下了回车键
self.browser.get(self.live_server_url)
self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
# 首页刷新了,显示一个错误消息
# 提示待办事项不能为空
self.assertEqual(
self.browser.find_element_by_css_selector('.has-error').text, ➊
"You can't have an empty list item" ➋
)
# 她输入一些文字,然后再次提交,这次没问题了
self.fail('finish this test!')
[...]
这可想得太天真了。
❶ 通过 CSS 类 .has-error
查找错误文本。Bootstrap 为错误文本提供了很多有用的样式。
❷ 确认错误文本中有我们想显示的消息。
不过,你能看出这个测试有什么潜在问题吗?
好吧,本节的标题已经给出提示:如果页面刷新,就要显式等待;否则,Selenium 可能会在页面加载之前查找 .has-error
元素。
只要通过
Keys.ENTER
或点击按钮提交表单后页面要刷新,在下一个断言之前可能就需要显式等待。
首次需要显式等待时,我们定义了一个辅助方法。但对这个测试来说,你可能觉得用辅助方法有点小题大做。不过,在测试中能使用通用的方式表达“等到断言通过”也不错,比如这样:
functional_tests/test_list_item_validation.py (ch11l009)
[...]
# 首页刷新了,显示了一条错误消息
# 提示待办事项不能为空
self.wait_for(lambda: self.assertEqual( ➊
self.browser.find_element_by_css_selector('.has-error').text,
"You can't have an empty list item"
))
➊ 不再直接调用断言,而是把断言包装到一个 lambda
函数中,然后再把它传给一个打算命名为 wait_for
的辅助方法。
如果你从未在 Python 中见过
lambda
函数,请阅读后文“lambda
函数”。
那应该如何实现这个 wait_for
方法呢?打开 base.py,复制现有的 wait_for_row_in_list_table
方法,稍微修改一下:
functional_tests/base.py (ch11l010)
def wait_for(self, fn): ➊
start_time = time.time()
while True:
try:
table = self.browser.find_element_by_id('id_list_table') ➋
rows = table.find_elements_by_tag_name('tr')
self.assertIn(row_text, [row.text for row in rows])
return
except (AssertionError, WebDriverException) as e:
if time.time() - start_time > MAX_WAIT:
raise e
time.sleep(0.5)
❶ 复制现有的方法,但将其重命名为 wait_for
并修改参数,期待传入一个函数。
❷ 现在的代码仍然检查表格中的行。怎样修改才能支持传入的 fn
函数呢?像这样:
functional_tests/base.py (ch11l011)
def wait_for(self, fn):
start_time = time.time()
while True:
try:
return fn() ➊
except (AssertionError, WebDriverException) as e:
if time.time() - start_time > MAX_WAIT:
raise e
time.sleep(0.5)
➊ try/except
的主体不再检查表格中的行,而是调用传入的函数。如果没有异常抛出,就返回传入的那个函数的返回值,立即跳出循环。
lambda
函数在Python 中,一次性单行函数使用
lambda
构建,这样免去了使用def..():
和缩进代码块的麻烦。
>>> myfn = lambda x: x+1
>>> myfn(2)
3
>>> myfn(5)
6
>>> adder = lambda x, y: x + y
>>> adder(3, 2)
5
在下述示例中,我们把一段本应立即执行的代码定义为一个函数,然后作为参数传给
lambda
函数,留待以后执行,而且可以多次执行:
>>> def addthree(x):
… return x + 3
…
>>> addthree(2)
5
>>> myfn = lambda: addthree(2) # 注意,这里的addthree不会立即调用
>>> myfn
<function <lambda> at 0x7f3b140339d8>
>>> myfn()
5
>>> myfn()
5
下面来看 wait_for
辅助方法的实际效果:
- $ python manage.py test functional_tests.test_list_item_validation
- [...]
- ======================================================================
- ERROR: test_cannot_add_empty_list_items
- (functional_tests.test_list_item_validation.ItemValidationTest)
- ---------------------------------------------------------------------
- Traceback (most recent call last):
- File "...superlists/functional_tests/test_list_item_validation.py", line
- 15, in test_cannot_add_empty_list_items
- self.wait_for(lambda: self.assertEqual( ➊
- File "...superlists/functional_tests/base.py", line 37, in wait_for
- raise e ➋
- File "...superlists/functional_tests/base.py", line 34, in wait_for
- return fn() ➋
- File "...superlists/functional_tests/test_list_item_validation.py", line
- 16, in <lambda> ➌
- self.browser.find_element_by_css_selector('.has-error').text, ➌
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: .has-error
- ---------------------------------------------------------------------
- Ran 1 test in 10.575s
- FAILED (errors=1)
调用跟踪的顺序有点儿乱,不过我们或多或少能猜到到底发生了什么。
❶ 功能测试的第 15 行进入 self.wait_for
辅助方法,传入 assertEqual
的 lambda 版本。
❷ 进入 base.py 中的 self.wait_for
,可以看到我们调用了那个 lambda
函数,但是由于超时而抛出 raise e
。
❸ 为了查明异常来自何处,调用跟踪又把我们带到 test_list_item_validation.py 文件中的 lambda
函数主体里,并且告诉我们,是在尝试查找 .has-error
元素时失败的。
现在我们进入了函数式编程领域,即把一个函数作为参数传给另一个函数——这可能有点烧脑,我当初也是历经一番坎坷才理解的。多读几遍代码和功能测试,慢慢体会。如果还是不能理解,也别烦忧,在使用的过程中慢慢领会。本书将多次使用函数式编程,你会领略它的强大之处的。
12.3 补完功能测试
把功能测试补完:
functional_tests/test_list_item_validation.py (ch11l012)
# 首页刷新了,显示一条错误消息
# 提示待办事项不能为空
self.wait_for(lambda: self.assertEqual(
self.browser.find_element_by_css_selector('.has-error').text,
"You can't have an empty list item"
))
# 她输入一些文字,然后再次提交,这次没问题了
self.browser.find_element_by_id('id_new_item').send_keys('Buy milk')
self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: Buy milk')
# 她有点儿调皮,又提交了一个空待办事项
self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
# 她在列表页面看到了一条类似的错误消息
self.wait_for(lambda: self.assertEqual(
self.browser.find_element_by_css_selector('.has-error').text,
"You can't have an empty list item"
))
# 输入文字之后就没问题了
self.browser.find_element_by_id('id_new_item').send_keys('Make tea')
self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: Buy milk')
self.wait_for_row_in_list_table('2: Make tea')
功能测试中的辅助方法
至此,我们定义了两个辅助方法:
self.wait_for
和wait_for_row_in_list_table
。前者是通用的,任何功能测试都可能需要等待。后者还有助于避免功能测试出现重复的代码。如果有一天我们决定改变清单表格的实现,只需修改一个地方,而不用在功能测试中四处修改。
第 25 章和附录 E 还将深入讨论如何合理规划功能测试。
这次提交留给你自己完成,这是你第一次独立提交功能测试。
12.4 重构单元测试,分拆成多个文件
最终开始编写代码前,要为模型编写一个新测试,但在此之前,先要使用类似于功能测试的整理方法整理单元测试。
但这一次有所不同,因为 lists
应用中既有应用代码也有测试代码,所以要把测试放到单独的文件夹中:
- $ mkdir lists/tests
- $ touch lists/tests/__init__.py
- $ git mv lists/tests.py lists/tests/test_all.py
- $ git status
- $ git add lists/tests
- $ python manage.py test lists
- [...]
- Ran 9 tests in 0.034s
- OK
- $ git commit -m "Move unit tests into a folder with single file"
如果测试的输出显示“Ran 0 tests”,有可能是因为你忘了创建 init .py 文件——必须有这个文件,否则测试所在的文件夹就不是有效的 Python 包。
现在把 test_all.py 分成两个文件:一个名为 test_views.py,只包含视图测试;另一个名为 test_models.py。先复制两份:
- $ git mv lists/tests/test_all.py lists/tests/test_views.py
- $ cp lists/tests/test_views.py lists/tests/test_models.py
然后清理 test_models.py,只留下一个测试方法,所以导入的模块也更少了:
lists/tests/test_models.py (ch11l016)
from django.test import TestCase
from lists.models import Item, List
class ListAndItemModelsTest(TestCase):
[...]
test_views.py 只减少了一个类:
lists/tests/test_views.py (ch11l017)
--- aliststests/test_views.py
+++ bliststests/test_views.py
@@ -103,34 +104,3 @@ class ListViewTest(TestCase):
self.assertNotContains(response, 'other list item 1')
self.assertNotContains(response, 'other list item 2')
-
-
-class ListAndItemModelsTest(TestCase):
-
- def test_saving_and_retrieving_items(self):
[...]
再次运行测试,确保一切正常:
- $ python manage.py test lists
- [...]
- Ran 9 tests in 0.040s
- OK
很好!
- $ git add lists/tests
- $ git commit -m "Split out unit tests into two files"
有些人喜欢在项目一开始就把单元测试放在一个测试文件夹中。这种做法很棒。我只是想等到必要时再这么做,以免第一章内容过杂。
至此,功能测试和单元测试的组织方式更合理了。下一章将探讨验证规则。
关于组织测试和重构的小贴士
把测试放在单独的文件夹中
就像使用多个文件保存应用代码一样,你也应该把测试放到多个文件中。
- 对功能测试来说,按照特定功能或用户故事的方式组织。
- 对单元测试来说,使用一个名为 tests 的文件夹,并在其中添加 init.py 文件。
- 或许可以把针对一个源码文件的测试放在一个单独的文件中。在 Django 中,往往有 test_models.py、test_views.py 和 test_forms.py。
- 每个函数和类都至少有一个占位测试。
别忘了“遇红/变绿/重构”中的“重构”
编写测试的主要目的是让你重构代码!一定要重构,尽量让代码(包括测试)变得简洁。
测试失败时别重构
- 一般情况下如此。
- 不算正在处理的功能测试。
- 如果测试的对象还没实现,可以先为测试方法加上
@skip
装饰器。- 更常见的做法是:记下想重构的地方,完成手头上的活儿,等应用处于正常状态时再重构。
- 提交代码之前别忘了删掉所有
@skip
装饰器!一定要逐行审查差异,找出需要删除的每一个地方。尝试通用的
wait_for
辅助方法使用专门的辅助方法实现显式等待是个不错的主意,能让测试更易于阅读,但有时单行断言或 Selenium 交互也需要等待一段时间。
self.wait_for
很符合我的需求,不过你可能需要稍微不同的方式。
第 13 章 数据库层验证
接下来的几章将实现并测试用户输入验证。
这里有相当一部分内容是专门针对 Django 的,很少涉及 TDD 原理。但这并不意味着你学不到关于测试的新知识——其实我们将讨论很多有趣的测试小知识点,但将更关注如何适应测试、如何跟上 TDD 的节奏以及如何完成手上的工作。
这三章都很短,学完之后,我们将稍微接触一下 JavaScript,然后结束第二部分。第三部分将深入讨论 TDD 方法论的细节,比如比较单元测试和整合测试、介绍模拟技术等。敬请期待!
不过现在要稍微讨论一下验证。先运行功能测试,看一下目前进展到哪里了:
- $ python3 manage.py test functional_tests.test_list_item_validation
- [...]
- ======================================================================
- ERROR: test_cannot_add_empty_list_items
- (functional_tests.test_list_item_validation.ItemValidationTest)
- ---------------------------------------------------------------------
- Traceback (most recent call last):
- File "...superlists/functional_tests/test_list_item_validation.py", line
- 15, in test_cannot_add_empty_list_items
- self.wait_for(lambda: self.assertEqual(
- [...]
- File "...superlists/functional_tests/test_list_item_validation.py", line
- 16, in <lambda>
- self.browser.find_element_by_css_selector('.has-error').text,
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: .has-error
功能测试指明,用户输入空待办事项时,期望看到一个错误消息。
13.1 模型层验证
Web 应用的验证放在两个地方:客户端(稍后会看到,使用的是 JavaScript 或 HTML5 属性)和服务器端。在服务器端验证更安全,因为一旦有漏洞或缺陷,客户端验证可以轻易绕过。
在服务器端,尤其是对 Django 而言,也有两个地方可以执行验证:一个是模型层;一个是表单层,位置较高。只要可行,我就会使用低层验证,一方面因为我喜欢数据库和数据库完整性规则,另一方面因为在这一层执行验证更安全——你有时会忘记使用了哪个表单验证输入,但使用的数据库不会变。
13.1.1 self.assertRaises
上下文管理器
下面在模型层编写一个单元测试。在 ListAndItemModelsTest
中添加一个新测试方法,尝试创建一个空待办事项。这个测试与以往不同,我们要测试的是代码能抛出异常:
lists/tests/test_models.py (ch11l018)
from django.core.exceptions import ValidationError
[...]
class ListAndItemModelsTest(TestCase):
[...]
def test_cannot_save_empty_list_items(self):
list_ = List.objects.create()
item = Item(list=list_, text='')
with self.assertRaises(ValidationError):
item.save()
如果刚接触 Python,可能从未见过
with
语句。它结合“上下文管理器”一起使用,包装一段代码,这些代码的作用往往是设置、清理或处理错误。Python 2.5 的发布说明中有很好的解说。
这是一个新的单元测试技术:如果想检查做某件事是否会抛出异常,可以使用 self.assertRaises
上下文管理器。此外还可写成:
try:
item.save()
self.fail('The save should have raised an exception')
except ValidationError:
pass
不过使用 with 语句更简洁。现在运行测试,得到预期失败:
item.save()
AssertionError: ValidationError not raised
13.1.2 Django怪异的表现:保存时不验证数据
我们遇到了 Django 的一个怪异表现。测试本来应该通过的。阅读 Django 模型字段的文档之后,你会发现 TextField
的默认设置是 blank=False
,也就是说文本字段应该拒绝空值。
但为什么测试失败了呢?由于稍微有违常理的历史原因,保存数据时 Django 的模型不会运行全部验证。稍后我们会看到,在数据库中实现的约束,保存数据时都会抛出异常,但 SQLite 不支持文本字段上的强制空值约束,所以我们调用 save
方法时无效值悄无声息地通过了验证。
有种方法可以检查约束是否会在数据库层执行:如果在数据库层制定约束,需要执行迁移才能应用约束。但是,Django 知道 SQLite 不支持这种约束,所以如果运行 makemigrations
,会看到消息说没事可做:
- $ python manage.py makemigrations
- No changes detected
不过,Django 提供了一个方法用于运行全部验证,即 full_clean
。下面我们把这个方法加入测试,看看是否有用:
lists/tests/test_models.py
with self.assertRaises(ValidationError):
item.save()
item.full_clean()
加入之后,测试就通过了:
OK
很好!我们通过这个怪异的表现学到了一些 Django 的验证知识。如果忘了需求,把 text
字段的约束条件设为 blank=True
(试一下吧),测试可以提醒我们。
13.2 在视图中显示模型验证错误
下面尝试在视图中处理模型验证,并把验证错误传入模板,让用户看到。在 HTML 中有选择地显示错误可以使用这种方法——检查是否有错误变量传入模板,如果有就在表单下方显示出来:
lists/templates/base.html (ch11l020)
<form method="POST" action="{% block form_action %}{% endblock %}">
<input name="item_text" id="id_new_item"
class="form-control input-lg"
placeholder="Enter a to-do item" >
{% csrf_token %}
{% if error %}
<div class="form-group has-error">
<span class="help-block">{{ error }}<span>
</div>
{% endif %}
</form>
关于表单控件的更多信息请阅读 Bootstrap 文档。
把错误传入模板是视图函数的任务。看一下 NewListTest
类中的单元测试,这里我要使用两种稍微不同的错误处理模式。
在第一种情况中,新建清单视图有可能渲染首页所用的模板,而且还会显示错误消息。单元测试如下:
lists/tests/test_views.py (ch11l021)
class NewListTest(TestCase):
[...]
def test_validation_errors_are_sent_back_to_home_page_template(self):
response = self.client.post('listsnew', data={'item_text': ''})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'home.html')
expected_error = "You can't have an empty list item"
self.assertContains(response, expected_error)
编写这个测试时,我们手动输入了字符串形式的地址 listsnew,你可能有点反感。在此之前,我们已经在测试、视图和模板中硬编码了多个地址,这么做有违 DRY 原则。我并不介意测试中有少量重复,但视图和模板中硬编码的地址要引起重视,请在便签上做个记录,稍后重构这些地址。我们不会立即开始重构,因为现在应用无法正常运行,得先让应用回到可运行的状态。
再看测试。现在测试无法通过,因为现在视图返回 302 重定向,而不是正常的 200 响应:
AssertionError: 302 != 200
我们在视图中调用 full_clean()
试试:
lists/views.py
def new_list(request):
list_ = List.objects.create()
item = Item.objects.create(text=request.POST['item_text'], list=list_)
item.full_clean()
return redirect(f'lists{list_.id}/')
看到这个视图的代码,我们找到了一种避免硬编码 URL 的好方法。把这件事记在便签上:
现在模型验证会抛出异常,并且传到了视图中:
[...]
File "...superlistslistsviews.py", line 11, in new_list
item.full_clean()
[...]
django.core.exceptions.ValidationError: {'text': ['This field cannot be
blank.']}
下面使用第一种错误处理方案:使用 try/except
检测错误。遵从测试山羊的教诲,只加入 try/except
,其他代码都不动。测试会告诉我们下一步要编写什么代码:
lists/views.py (ch11l025)
from django.core.exceptions import ValidationError
[...]
def new_list(request):
list_ = List.objects.create()
item = Item.objects.create(text=request.POST['item_text'], list=list_)
try:
item.full_clean()
except ValidationError:
pass
return redirect(f'lists{list_.id}/')
加入 try/except
之后,测试结果又变成了 302 != 200 错误:
AssertionError: 302 != 200
下面把 pass
改成渲染模板,这么改还兼具检查模板的功能:
lists/views.py (ch11l026)
except ValidationError:
return render(request, 'home.html')
现在测试告诉我们,要把错误消息写入模板:
AssertionError: False is not true : Couldn't find 'You can't have an empty list
item' in response
为此,可以传入一个新的模板变量:
lists/views.py (ch11l027)
except ValidationError:
error = "You can't have an empty list item"
return render(request, 'home.html', {"error": error})
不过,看样子没什么用:
AssertionError: False is not true : Couldn't find 'You can't have an empty list
item' in response
让视图输出一些信息以便调试:
lists/tests/test_views.py
expected_error = "You can't have an empty list item"
print(response.content.decode())
self.assertContains(response, expected_error)
从输出的信息中我们得知,失败的原因是 Django 转义了 HTML 中的单引号:
[...]
<span class="help-block">You can't have an empty list
item</span>
可以在测试中写入:
expected_error = "You can't have an empty list item"
但使用 Django 提供的辅助函数或许是更好的方法:
lists/tests/test_views.py (ch11l029)
from django.utils.html import escape
[...]
expected_error = escape("You can't have an empty list item")
self.assertContains(response, expected_error)
测试通过了:
Ran 11 tests in 0.047s
OK
确保无效的输入值不会存入数据库
继续做其他事之前,不知你是否注意到了我们的实现有点逻辑错误?现在即使验证失败仍会创建对象:
lists/views.py
item = Item.objects.create(text=request.POST['item_text'], list=list_)
try:
item.full_clean()
except ValidationError:
[...]
要添加一个新单元测试,确保不会保存空待办事项:
lists/tests/test_views.py (ch11l030-1)
class NewListTest(TestCase):
[...]
def test_validation_errors_are_sent_back_to_home_page_template(self):
[...]
def test_invalid_list_items_arent_saved(self):
self.client.post('listsnew', data={'item_text': ''})
self.assertEqual(List.objects.count(), 0)
self.assertEqual(Item.objects.count(), 0)
测试的结果是:
[...]
Traceback (most recent call last):
File "...superlistsliststests/test_views.py", line 40, in
test_invalid_list_items_arent_saved
self.assertEqual(List.objects.count(), 0)
AssertionError: 1 != 0
修正的方法如下:
lists/views.py (ch11l030-2)
def new_list(request):
list_ = List.objects.create()
item = Item(text=request.POST['item_text'], list=list_)
try:
item.full_clean()
item.save()
except ValidationError:
list_.delete()
error = "You can't have an empty list item"
return render(request, 'home.html', {"error": error})
return redirect(f'lists{list_.id}/')
功能测试能通过吗?
- $ python manage.py test functional_tests.test_list_item_validation
- [...]
- File "...superlists/functional_tests/test_list_item_validation.py", line
- 29, in test_cannot_add_empty_list_items
- self.wait_for(lambda: self.assertEqual(
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: .has-error
不能完全通过,但取得了一些进展。从 line 29
那行能看出,功能测试的第一部分通过了,现在要处理第二部分,即第二次提交空待办事项也要显示错误消息。
不过我们编写了一些可用的代码,那就做个提交吧:
- $ git commit -am "Adjust new list view to do model validation"
13.3 Django模式:在渲染表单的视图中处理POST请求
这一次要使用一种稍微不同的处理方式,这种方式是 Django 中十分常用的模式:在渲染表单的视图中处理该视图接收到的 POST 请求。这么做虽然不太符合 REST 架构的 URL 规则,却有个很大的好处:同一个 URL 既可以显示表单,又可以显示处理用户输入过程中遇到的错误。
现在的状况是,显示清单用一个视图和 URL,处理新建清单中的待办事项用另一个视图和 URL。要把这两种操作合并到一个视图和 URL 中。所以,在 list.html 中,表单的提交目标地址要改一下:
lists/templates/list.html (ch11l030)
{% block form_action %}lists{{ list.id }}/{% endblock %}
不小心又硬编码了一个 URL,在便签上记下这个地方。回想一下,home.html 中也有一个:
修改之后功能测试随即失败,因为 view_list
视图还不知道如何处理 POST 请求:
- $ python manage.py test functional_tests
- [...]
- selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
- element: .has-error
- [...]
- AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
- peacock feathers']
本节要进行一次应用层的重构。在应用层中重构时,要先修改或增加单元测试,然后再调整代码。使用功能测试检查重构是否完成,以及一切能否像重构前一样正常运行。如果你想完全理解这个过程,请再看一下第 4 章末尾的图表。
13.3.1 重构:把new_item
实现的功能移到view_list
中
NewItemTest
类中的测试用于检查把 POST 请求中的数据保存到现有的清单中,把这些测试全部移到 ListViewTest
类中,还要把原来的请求目标地址 …/add_item 改成显示清单的 URL:
lists/tests/test_views.py (ch11l031)
class ListViewTest(TestCase):
def test_uses_list_template(self):
[...]
def test_passes_correct_list_to_template(self):
[...]
def test_displays_only_items_for_that_list(self):
[...]
def test_can_save_a_POST_request_to_an_existing_list(self):
other_list = List.objects.create()
correct_list = List.objects.create()
self.client.post(
f'lists{correct_list.id}/',
data={'item_text': 'A new item for an existing list'}
)
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new item for an existing list')
self.assertEqual(new_item.list, correct_list)
def test_POST_redirects_to_list_view(self):
other_list = List.objects.create()
correct_list = List.objects.create()
response = self.client.post(
f'lists{correct_list.id}/',
data={'item_text': 'A new item for an existing list'}
)
self.assertRedirects(response, f'lists{correct_list.id}/')
注意,整个 NewItemTest
类都没有了。而且我还修改了重定向测试方法的名字,明确表明只适用于 POST 请求。
改动之后测试的结果为:
FAIL: test_POST_redirects_to_list_view (lists.tests.test_views.ListViewTest)
AssertionError: 200 != 302 : Response didn't redirect as expected: Response
code was 200 (expected 302)
[...]
FAIL: test_can_save_a_POST_request_to_an_existing_list
(lists.tests.test_views.ListViewTest)
AssertionError: 0 != 1
然后修改 view_list
函数,处理两种请求类型:
lists/views.py (ch11l032-1)
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
if request.method == 'POST':
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect(f'lists{list_.id}/')
return render(request, 'list.html', {'list': list_})
修改之后测试通过了:
Ran 12 tests in 0.047s
OK
现在可以删除 add_item
视图,因为不再需要了。但出乎意料,一些测试失败了:
[...]
AttributeError: module 'lists.views' has no attribute 'add_item'
失败的原因是,虽然删除了视图,但在 urls.py 中仍然引用这个视图。把引用也删除:
lists/urls.py (ch11l033)
urlpatterns = [
url(r'^new$', views.new_list, name='new_list'),
url(r'^(\d+)/$', views.view_list, name='view_list'),
]
这样单元测试就能通过了。运行所有功能测试看看结果如何:
- $ python manage.py test
- [...]
- ERROR: test_cannot_add_empty_list_items
- [...]
- Ran 16 tests in 15.276s
- FAILED (errors=1)
依然是那个新功能测试中的失败。至此,重构 add_item
功能的任务完成了。此时应该提交代码:
- $ git commit -am "Refactor list view to handle new item POSTs"
我是不是破坏了“有测试失败时不重构”这个规则?本节可以这么做,因为若想使用新功能必须重构。如果有单元测试失败,决不能重构。不过在本书中,虽然当前这个用户故事的功能测试失败了,但仍然可以重构。1
1如果你更想看到一个干净的测试结果,那么可以为这个功能测试方法加上 @skip
装饰器,或者在测试方法中尽早返回。但是,别忘了你这么做过。
13.3.2 在view_list
视图中执行模型验证
把待办事项添加到现有清单时,我们希望保存数据时仍能遵守制定好的模型验证规则。为此要编写一个新单元测试,和首页的单元测试差不多,但有几处不同:
lists/tests/test_views.py (ch11l034)
class ListViewTest(TestCase):
[...]
def test_validation_errors_end_up_on_lists_page(self):
list_ = List.objects.create()
response = self.client.post(
f'lists{list_.id}/',
data={'item_text': ''}
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'list.html')
expected_error = escape("You can't have an empty list item")
self.assertContains(response, expected_error)
这个测试应该失败,因为视图现在还没做任何验证,只是重定向所有 POST 请求:
self.assertEqual(response.status_code, 200)
AssertionError: 302 != 200
在视图中执行验证的方法如下:
lists/views.py (ch11l035)
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
error = None
if request.method == 'POST':
try:
item = Item(text=request.POST['item_text'], list=list_)
item.full_clean()
item.save()
return redirect(f'lists{list_.id}/')
except ValidationError:
error = "You can't have an empty list item"
return render(request, 'list.html', {'list': list_, 'error': error})
对这段代码不是特别满意,是吧?确实有一些重复的代码,views.py 中出现了两次 try/except
语句,一般来说不太好看。
Ran 13 tests in 0.047s
OK
稍等一会儿再重构,因为我们知道验证待办事项重复的代码有点不同。先把这件事记在便签上:
制定“事不过三,三则重构”这个规则的原因之一是,只有遇到三次且每次都稍有不同时,才能更好地提炼出通用功能。如果过早重构,得到的代码可能并不适用于第三次。
至少功能测试又可以通过了:
- $ python manage.py test functional_tests
- [...]
- OK
又回到了可正常运行的状态,因此可以看一下便签上的记录了。现在是提交的好时机。或许还可以喝杯茶休息一下。
- $ git commit -am "enforce model validation in list view"
13.4 重构:去除硬编码的URL
还记得 urls.py 中 name=
参数的写法吗?我们是直接从 Django 生成的默认 URL 映射中复制过来,然后又给它们起了有意义的名字。现在要查明这些名字有什么用。
lists/urls.py
url(r'^new$', views.new_list, name='new_list'),
url(r'^(\d+)/$', views.view_list, name='view_list'),