建立电商网站项目

在之前的章节,已经建立过了博客网站和社交网站的雏形,在今后建立站点和使用站点的时候,大概也会知道网站是如何实现该功能的。现在几种流行的网站,除了以博客为代表的内容管理网站,社交网站,还有电商网站,提供内容服务的在线教学网站等。

现在就要开始建立本书的第三个项目,电商网站。电商网站是目前非常流行的网站形式,其核心内容是如何处理商品列表,购物车和用户订单这些功能,以及集成支付功能。一般电商网站,是集成功能最多也最复杂的网站。

本章的要点有:

  • 建立产品目录
  • 使用session建立购物车
  • 管理用户订单
  • 使用Celery向用户发送异步通知

建立商品展示系统

在建立电商网站前,还是要思考一下电商网站的核心内容。用户会通过浏览商品页面,将一些商品加入购物车,然后在购物车中编辑,留下自己需要的商品,最后通过购物车去下订单。将这些过程拆分一下,就可以得到要做的事情:

  • 建立商品清单数据类,加入到管理后台,展示商品清单
  • 建立购物车系统,核心功能是在用户浏览网站的时候保存他们的选择
  • 建立提交订单的页面
  • 订单提交成功后异步发送邮件给用户

明确了任务之后,就开启一个新项目叫myshop,然后启动一个应用叫shop,将应用添加到settings.py中。依然推荐使用virtual env 建立虚拟环境。

建立商品数据类

对于商品来讲,参考一下流行的电商网站,可以发现商品需要属于一个品类,在那个品类之下,每个商品至少要有名称,可选的描述,可选的商品图片,价格,是否可用等数据。为此建立两个表,一个是商品品类,一个是商品,编辑shop应用下的model.py来建立对应的数据类:

from django.db import models


class Category(models.Model):
    name = models.CharField(max_length=200, db_index=True)
    slug = models.SlugField(max_length=200, db_index=True, unique=True)

    class Meta:
        ordering = ('name',)
        verbose_name = 'category'
        verbose_name_plural = 'categories'

    def __str__(self):
        return self.name


class Product(models.Model):
    category = models.ForeignKey(Category, related_name='category', on_delete=models.CASCADE)
    name = models.CharField(max_length=200, db_index=True)
    slug = models.SlugField(max_length=200, db_index=True)
    image = models.ImageField(upload_to='products/%Y/%m/%d', blank=True)
    description = models.TextField(blank=True)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    available = models.BooleanField(default=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ('name',)
        index_together = (('id', 'slug'),)

    def __str__(self):
        return self.name

需要解释的地方有:

  • 处理金额相关的数据时,一定要使用DecimalField而不是FloadField,前者在Python 中对应Decimal类,后者就是普通双精度浮点。采用前者不会出现小数计算错误。
  • Meta类中的index_together属性,表示联合索引。我们打算同时通过id和slug来索引商品,所以编制了联合索引,可以加速查询。联合索引是数据库中的概念。
  • 由于存在ImageField,如果启用了虚拟环境,在进行migrate系列操作之前,还需要安装Python的Pillow模块(推荐5.1版)

之后可以进行makemigration 和 migrate完成数据表建立。

将两个数据模型注册到管理后台中

对于重要的数据类,一般都要加到管理后台中去方便操作。这里还要提一下的是,第三方数据库一般也有图形化的界面,比如用于MySQL系列的NaviCat,用于PostgreSQL的PgAdmin软件,结合这些软件和Django的管理后台,能够非常方便的直接在后端操作数据库。

在shop应用的admin.py内编写:

from django.contrib import admin
from .models import Category, Product


@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ['name', 'slug']
    prepopulated_fields = {'slug': ('name',)}


@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = ['name', 'slug', 'price', 'available', 'created', 'updated']
    list_filter = ['available', 'created', 'updated']
    list_editable = ['price', 'available']
    prepopulated_fields = {'slug': ('name',)}

这里有几个要解释的是:

  • prepopulated_fields表示用一个字段来生成另外一个字段,这里设置为使用name字段生成slug,这样可以方便的生成slug。
  • list_editable属性表示其中的字段可以在列表页直接进行修改,就不用一个个点进去修改了。
  • list_editable属性中的所有字段都必须包含在list_display属性中

之后不要忘记建立超级用户。之后登陆管理后台,添加一些商品品类和具体商品,可以看到如下的页面:

这里有个问题是图片上的stock字段是哪里来的?只能先继续往下看了。

建立商品品类视图

在shop应用里的views.py里编写商品品类的视图:

from django.shortcuts import render, get_object_or_404
from .models import Category, Product


def product_list(request, category_slug=None):
    category = None
    categories = Category.objects.all()
    products = Product.objects.filter(available=True)
    if category_slug:
        category = get_object_or_404(categories, slug=category_slug)
        products = products.filter(category=category)
    return render(request, 'shop/product/list.html',
                  {'category': category, 'categories': categories, 'products': products})

这个视图额外带一个默认为None的参数,说明我们之后要通过URL来传参数和自定义get_absolute_url方法。业务逻辑是先设置品类名为None,然后获得所有的商品。如果没有传参数,就把所有品类和可用商品传给模板。如果传入了具体的品类,就将该品类,全部品类列表和该品类的商品发给模板。

由于我们比较熟练了,就不用先建立模板了,可以直接再编写一个展示商品详情的视图:

def product_detail(request, id, slug):
    product = get_object_or_404(Product, id=id, slug=slug, availbable=True)
    return render(request, 'shop/product/detail.html', {'product': product})

两个视图函数都带了参数,在shop应用目录内建立urls.py,在其中编写:

from django.urls import path
from . import views

app_name = 'shop'

urlpatterns = [
    path('', views.product_list, name='product_list'),
    path('<slug:category_slug>/', views.product_list, name='product_list_by_category'),
    path('<int:id>/<slug:slug>/', views.product_detail, name='product_detail'),
]

这里要解释的就是product_list视图带一个默认值参数,所以默认路径进来后就是展示全部品类的页面。加上了具体某个品类,就展示那个品类的商品。详情页的URL使用id和slug来进行参数传递。

我们这是shop应用的二级路由,然后就需要编写项目的一级路由:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('shop.urls', namespace='shop')),
]

这个配置也很熟悉了,这里提一下就是include的二级路由的命名空间,在shop应用的urls.py里需要写上app_name = 'shop'

既然使用了URL传参数给视图函数,很显然就需要编写两个数据类的get_absolute_url方法了,在shop应用的models.py里编辑:

class Category(models.Model):
    def get_absolute_url(self):
        return reverse('shop:product_list_by_category',args=[self.slug])

class Product(models.Model):
    def get_absolute_url(self):
        return reverse('shop:product_detail',args=[self.id,self.slug])

就像之前做过很多次的这样,采用reverse反向解析并且将当前对象的参数传入来返回一个URL地址,就构建了每个对象生成URL地址的方法。

编写商品模板

与之前两个项目类似,在shop应用下建立一系列目录和文件如下:

templates/
    shop/
        base.html
        product/
            list.html
            detail.html

我们依然是打算让其他模板来继承base.html,先编辑base.html:

{% load static %}
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>{% block title %}My shop{% endblock %}</title>
    <link href="{% static "css/base.css" %}" rel="stylesheet">
</head>
<body>
<div id="header">
    <a href="/" class="logo">My shop</a>
</div>
<div id="subheader">
    <div class="cart">
        Your cart is empty.
    </div>
</div>
<div id="content">
    {% block content %}
    {% endblock %}
</div>
</body>
</html>

BASE模板分为了三大部分:标题,购物车和内容。看到CSS就要想到从随书源码中将CSS文件COPY过来。顺便把同一个static目录下的img目录也拷贝过来,里边是默认的没有图片商品在页面上展示的图片。

然后编写list.html

{% extends "shop/base.html" %}
{% load static %}

{% block title %}
    {% if category %}{{ category.name }}{% else %}Products{% endif %}
{% endblock %}

{% block content %}
    <div id="sidebar">
        <h3>Categories</h3>
        <ul>
            <li {% if not category %}class="selected"{% endif %}>
                <a href="{% url "shop:product_list" %}">All</a>
            </li>
            {% for c in categories %}
                <li {% if category.slug == c.slug %}class="selected"{% endif %}>
                    <a href="{{ c.get_absolute_url }}">{{ c.name }}</a>
                </li>
            {% endfor %}
        </ul>
    </div>
    <div id="main" class="product-list">
        <h1>{% if category %}{{ category.name }}{% else %}Products{% endif %}</h1>
        {% for product in products %}
            <div class="item">
                <a href="{{ product.get_absolute_url }}">
                    <img src="
                            {% if product.image %}{{ product.image.url }}{% else %}{% static "img/no_image.png" %}{% endif %}">
                </a>
                <a href="{{ product.get_absolute_url }}">{{ product.name }}</a><br>
                ${{ product.price }}
            </div>
        {% endfor %}
    </div>
{% endblock %}

这个模板分为上下两部分,一个是侧边栏,一个是内容部分。侧边栏通过category变量判断具体品类存在与否,不存在就显示所有品类和链接,存在就切换一个CSS类selected。内容部分也判断具体品类存在与否,不存在就展示全部商品,存在就只展示该类的商品。

如果商品没有图片,就展示默认的图片。既然要存放图片,则需要在settings.py和urls各里配置一下:

# settings.py
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')

# main urls.py
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

完成了图片文件路径配置之后,添加一些商品和图片,然后启动站点,可以看到类似于下边的页面:

如果没有给商品上传图片,显示就是这样:

然后编写商品详情页detail.html:

{% extends "shop/base.html" %}
{% load static %}
{% block title %}
    {{ product.name }}
{% endblock %}
{% block content %}
    <div class="product-detail">
        <img src="{% if product.image %}{{ product.image.url }}{% else %}
{% static "img/no_image.png" %}{% endif %}">
        <h1>{{ product.name }}</h1>
        <h2><a href="{{ product.category.get_absolute_url }}">{{ product.category }}</a></h2>
        <p class="price">${{ product.price }}</p>
        {{ product.description|linebreaks }}
    </div>
{% endblock %}

商品详情页面示例如下:

到这里就把商品类别和详情页面做完了,没有用到什么新东西,数据结构还比之前的社交网站项目要简单一些,下边来实现购物车功能。

通过session功能建立购物车系统

在开始做之前依然要思考一下,常见网站的购物车,在浏览整个网站,离开又返回的时候,购物车中的内容都可以保持原来的状态,那么其中的数据一定也是保存在某处。

购物车还一个突出的特点就是,如果将购物车中的商品下了订单,该商品就会从购物车中消失。

将购物车抽象成一个对象的话,这个对象很像一个数据库,挑选商品的时候往其中加入商品以及数量,下订单的时候减少商品和对应的数量。那么这个数据库应该保存在哪里呢?

答案就是session里,session很适合用来存储当前用户的一些小数据。

了解Django的session模块

Django 的session模块在默认建立一个应用的时候,已经包含在settings.py里启用的中间件'django.contrib.sessions.middleware.SessionMiddleware'中。这个中间件会让所有的request都具有session属性。

request.session可以把它想象成是一个Python字典,可以存储任何Python的数据并且序列化我Json格式。简单的操作如下:

# 向session里存数据
request.session['foo'] = 'bar'

# 从session里取数据
request.session.get('foo')

# 删除session中的键值对
del request.session['foo']

session的详细介绍和命令可以参考本站的django 中使用session,该文章对应的版本是django 1.11.14,但与2.1没有区别。

这里需要注意一点的是,当用户先浏览站点再登录的时候,原来作为匿名用户的session会被一个新的认证用户的session所替代。很多电商网站有这样的功能:在未登录的情况下将商品加入购物车,在购买的时候进行登录,则购物车中的内容还在。这是因为后端在登录的时候,将匿名用户的session里的购物车数据复制到了登录用户的session中。

session模块的设置

django中可以配置session模块的一些参数,其中最重要的是 SESSION_ENGINE设置,设置session具体存储在何处。默认情况下,django.contrib.session模块将session数据保存在默认生成的django_session表中。

Django提供了如下几种类型的session可供选择:

  • 存放于数据库中的session:默认设置,即将session数据存放到settings.py中的DATABASES设置中的数据库内。
  • 基于文件的session:保存在一个具体的文件中
  • 缓存的session:存储在django 的缓存系统中,可以通过CACHES设置缓存。这种情况下速度最快。(怀念Redis了吗?)
  • 缓存与数据库结合的方式:先存到缓存再持久化到数据库中。取数据时如果缓存内无数据,就从数据库中取。
  • Cookie方式:session存放在cookie中。

所有session相关的设置需要写在settings.py中,主要的设置有:

  • SESSION_COOKIE_AGE 为秒数,默认为1209600(两个星期)
  • SESSION_COOKIE_DOMAIN 默认是none,设置为某个主机名可以启用跨域cookie。
  • SESSION_COOKIE_SECURE 布尔值,默认为False,设置为True表示只允许HTTPS下使用session
  • SESSION_EXPIRE_AT_BROWSER_CLOSE 布尔值,默认为False,设置为True表示浏览器关闭就失效
  • SESSION_SAVE_EVERY_REQUEST 布尔值,默认为False,设置为True表示每次HTTP请求都会更新session,其中的过期时间相关设置也会一起更新。该项可以考虑修改成True。

详细内容当然还是要去看django的session 文档

这里特别要提一下的就是session的过期控制。在之前做两个项目的时候应该也会注意到,当我登录之后关闭页面的时候,下次再打开页面,依然处于登录状态,这就是因为session保存了登录状态。

SESSION_EXPIRE_AT_BROWSER_CLOSE默认为False,如果设置为True,则 SESSION_COOKIE_AGE中的设置不起作用。

在需要延长session过期的情况下,可以使用request.session.set_expiry()设置过期时间。

在session中存储购物车数据

存储购物车数据的关键有两个:一是确定存储内容,二是确定存储的方式。从前边session的介绍来看,存储方法打算采用JSON格式,而存储内容是:

  • 商品的id字段数据
  • 商品的数量
  • 商品的单位价格

这里有个问题是,商品的价格会变化。这里为了简便,我们在将商品加入购物车的同时存储当时商品的价格,如果商品价格之后再变动,也不去管了。(当然这和普通的电商网站是有区别的)

然后我们需要来实现购物车的功能,在这之前需要更细致的分析一下业务逻辑:

  1. 先检查session中是否存在购物车键,如果存在说明当前用户已经使用了购物车,如果不存在,就新建一个购物车键。
  2. 用户每次HTTP请求,都要重复第一步。
  3. 之后根据用户提交的操作,向购物车内添加或者删除数据。

下边来实现功能。先到settings.py里新增一行:

CART_SESSION_ID = 'cart'

这就是我们的购物车键名称,由于session对于每个用户都通过中间件管理,所以可以在所有用户的session里都使用统一的这个名称。

然后新建一个应用专门来控制购物车,启动新应用 cart,然后加入到settings.py中。在cart应用目录下新建cart.py然后编写:

from decimal import Decimal
from django.conf import settings
from shop.models import Product


class Cart(object):
    def __init__(self):
        """
        Initialize the cart.
        """
        self.session = request.session
        cart = self.session.get(settings.CART_SESSION_ID)
        if not cart:
            cart = self.session[settings.CART_SESSION_ID] = {}
        self.cart =cart

这里先编写了一个用来管理cart的类,考虑到每个用户都会有session,可以编写一个类,对应每个用户就实例化一个管理类。目前这个类只编写了初始化函数,里边的逻辑是判断request中是否有cart键,有就取过来,没有就新建一个空白键cart。

初始化了cart之后,下一步就是对cart进行修改。我们打算把商品ID作为键名,将数量和价格作为对应的值。这样可以保证不会重复添加相同的商品,同一个商品只会有一个唯一的键。

继续编写Cart类里的修改购物车功能:

class Cart(object):

    def add(self, product, quantity=1, update_quantity=False):
        """
        向购物车中增加商品或者更新购物车中的数量
        :param product: Product实例对象
        :param quantity: 增加商品的数量,为整数,默认为1
        :param update_quantity: False 表示在原有数量上增加,True表示覆盖原有数量
        :return: None
        """

        product_id = str(product.id)
        if product_id not in self.cart:
            self.cart[product_id] = {'quantity': 0, 'price': str(product.price)}
        if update_quantity:
            self.cart[product_id]['quantity'] = quantity
        else:
            self.cart[product_id]['quantity'] += quantity
        self.save()

    def save(self):
        # 设置session.modified的值为True,中间件在看到这个属性的时候,就会保存session
        self.session.modified = True

新增了add和save两个方法。add的功能在注释里已经基本写清楚了,具有设置新数量或增加数量的功能。对于每个session,建立一个cart键,然后其中的套一个product_id键,然后又有两个键price和quantity。一共是三层字典嵌套。

还需要编写从购物车中去掉商品的方法,逻辑比较简单,判断要删除的东西存在于购物车里,就删掉:

class Cart:

    def remove(self, product):
        """
        从购物车中删除商品
        :param product: 要删除的Product实例
        :return: None
        """
        product_id = str(product.id)
        if product_id in self.cart:
            del self.cart[product_id]
            self.save()

在后边展示购物车的界面中,很容易就想到需要遍历购物车内的所有商品。因此现在先写一个__iter()__方法,生成迭代器,供将for循环使用。

class Cart:

    def __iter__(self):
        """
        迭代所有购物车内的商品
        :return: 迭代器对象
        """
        product_ids = self.cart.keys()
        products = Product.objects.filter(id__in=product_ids)
        cart = self.cart.copy()
        for product in products:
            cart[str(product.id)]['product'] = product

        for item in cart.values():
            item['price'] = Decimal(item['price'])
            item['total_price'] = item['price'] * item['quantity']
            yield item

这段代码的逻辑是:取到所有购物车内商品的QuerySet,然后对购物车对象进行了一个浅拷贝。浅拷贝之后的局部变量cart的第一级键不会再变化,但是其中嵌套的字典依然可以变化。原来我们购物车的结构是{'cart': {'product_id': {'quantity': quantity, 'price': price}}},并且里边都是字符串。但是现在需要迭代其中的商品,就不能简单的返回字符串。

在浅拷贝之后,给这个购物车新增加了product键对应Product实例。

之后用临时变量item返回每个对象的内容,把item键price的值设置为Decimal类型,然后新增了一个总价字段,最后用yield返回每个item。此时每个item的结构是这样的:{'quantity': quantity, 'price': new_price, 'product': product, 'total_price': total_price},可以想象,未来通过for循环遍历的时候,只需要将这几个键的内容取出来就可以了。

考虑到购物车还需要显示其中一共有几件商品,因此再写一个__len__()方法,以让我们的类可以通过内置方法len来显示长度,实现逻辑就是遍历所有购物车内商品的quantity然后求和:

class Cart:

    def __len__(self):
        """
        购物车内一共有几种商品
        :return: INT
        """
        return sum(item['quantity'] for item in self.cart.values())

与这个方法相同的思路,再编写一个计算总价的方法:

class Cart:
    def get_total_price(self):
        return sum(Decimal(item['price']*item['quantity']) for item in self.cart.values())

最后,再编写一个清空购物车的功能。

class Cart:

    def clear(self):
        del self.session[settings.CART_SESSION_ID]
        self.save()

这样就编写完了这个类。这里的知识其实和session没有很大的关系,主要还是Python中编写类的特殊方法的知识。

这里还要提一下的是,原书的代码定义Cart类的时候是 class Cart(object):可能是作者为了兼容Python2的代码。这里没有按照原书的代码,而是统一按照Python3的新式类方法来编写。

建立购物车视图

建立完了管理购物车的类之后,就可以来编写视图了。购物车还记得最开始说类似一个小数据库,所以功能不外乎增删改查。视图的作用,就是将用户前端的增删改查动作获取到,然后调用购物车类来进行增删改查。

针对购物车类的功能,显然我们需要编写3个视图函数:

  • 一个视图函数用来增加或者更新购物车内的商品和数量(增和改)
  • 一个视图删除购物车内的商品(删)
  • 一个视图显示购物车内的详细内容和总价(查)

现在就来编写这三个视图。首先是增加和更新商品的视图。

为了把商品增加到购物车,除了具体商品的实例肯定在页面中获取了之外,还必须要让用户选择增加的数量。因此先到cart应用中建立form.py,在其中编写一个表单类:

from django import forms

PRODUCT_QUANTITY_CHOICES = [(i, str(i)) for i in range(1, 21)]


class CartAddProductForm(forms.Form):
    quantity = forms.TypedChoiceField(choices=PRODUCT_QUANTITY_CHOICES, coerce=int)
    update = forms.BooleanField(required=False, initial=False, widget=forms.HiddenInput)

这个表单包括两个字段,第一个字段是给用户进行选择数量,这里我们通过choices限定了用户选择的数量为1-20,使用了coerce=int表示将该字段的数据转换成整型。coerce参数只能使用在TypedChoiceFieldTypedMultipleChoiceField上。

第二个字段update,用于确认用户是增加数量还是覆盖原来的数量。如果为True,则覆盖,如果为False则增加,这里用了HiddeInput就不想给用户看了。

有了表单以后来建立视图,编写cart应用中的views.py:

from django.shortcuts import render, redirect, get_object_or_404
from django.views.decorators.http import require_POST
from shop.models import Product
from .cart import Cart
from .form import CartAddProductForm


@require_POST
def cart_add(request, product_id):
    # 实例化购物车对象
    cart = Cart(request)
    # 取得当前商品
    product = get_object_or_404(Product, id=product_id)
    # 取得POST表单对象
    form = CartAddProductForm(request.POST)
    # 如果表单验证通过,调用cart类的add方法然后跳转到购物车详情页面
    if form.is_valid():
        cd = form.cleaned_data
        cart.add(product=product, quantity=cd['quantity'], update_quantity=cd['update'])
    return redirect('cart:cart_detail')

业务逻辑已经写在了注释里,这个视图的功能非常类似京东的商品详情页面,点击加入购物车的按钮,之后会跳转到另外一个界面,可以返回商品页或者查看购物车,这里我们就直接跳转到了购物车详情页。

再来编写删除商品的视图:

def cart_remove(request, product_id):
    cart = Cart(request)
    product = get_object_or_404(Product, id=product_id)
    cart.remove(product)
    return redirect('cart:cart_detail')

删除方法很简单,继续来编写展示视图:

def cart_detail(request):
    cart = Cart(request)
    return render(request, 'cart/detail.html', {'cart': cart})

视图都编写好了之后,在cart应用里新建urls.py:

from django.urls import path
from . import views

app_name = 'cart'
urlpatterns = [
    path('', views.cart_detail, name='cart_detail'),
    path('add/<int:product_id>/', views.cart_add, name='cart_add'),
    path('remove/<int:product_id>/', views.cart_remove, name='cart_remove'),
]

在项目的一级路由内增加一条,将cart路径转发到cart应用的二级路由里:

path('cart/', include('cart.urls', namespace='cart')),

注意这一条路由需要增加在shop路径之前,因为shop路径被配置成默认路径,如果放在shop下边,则永远无法被匹配到。路由配置的时候,越严格的匹配越要往上放,最后用最宽泛的路径接着。

建立购物车相关模板

回想一下之前建立的base.html,其中为购物车留出了一行。此外,增加购物车的按钮应该出现在每个商品的详情页中,最后还要单独编写一个购物车清单页面。

建立:cart/templates/cart/detail.html:

{% extends 'shop/base.html' %}

{% load static %}

{% block title %}
    Your shopping cart
{% endblock %}

{% block content %}
    <h1>Your shopping cart</h1>
    <table class="cart">
        <thead>
        <tr>
            <th>Image</th>
            <th>Product</th>
            <th>Quantity</th>
            <th>Remove</th>
            <th>Unit price</th>
            <th>Price</th>
        </tr>
        </thead>
        <tbody>
        {% for item in cart %}
            {% with product=item.product %}
                <tr>
                    <td>
                        <a href="{{ product.get_absolute_url }}">
                            <img src="
                                    {% if product.image %}{{ product.image.url }}{% else %}{% static 'img/no_image.png' %}{% endif %}"
                                 alt="">
                        </a>
                    </td>
                    <td>{{ product.name }}</td>
                    <td>{{ item.quantity }}</td>
                    <td>
                        <a href="{% url 'cart:cart_remove' product.id %}">Remove</a>
                    </td>
                    <td class="num">${{ item.price }}</td>
                    <td class="num">${{ item.total_price }}</td>
                </tr>
            {% endwith %}
        {% endfor %}

            <tr class="total">
                <td>total</td>
                <td colspan="4"></td>
                <td class="num">${{ cart.get_total_price }}</td>
            </tr>
        </tbody>
    </table>
    <p class="text-right">
        <a href="{% url 'shop:product_list' %}" class="button light">Continue shopping</a>
        <a href="#" class="button">Checkout</a>
    </p>
{% endblock %}

虽然模板很长,但是逻辑很清晰,使用了一个表格展示购物车的内容。表头固定,表内的每一行通过迭代购物车中的每个item,展示对应的字段内容。还留出了删除该商品的链接。最后在表格底部展示购物车总价。

加入购物车的功能如前所述,需要修改商品详情页,注意我们为加入购物车建立了一个表单,所以先要修改product_detail视图函数:

def product_detail(request, id, slug):
    product = get_object_or_404(Product, id=id, slug=slug, available=True)
    cart_product_form = CartAddProductForm()
    return render(request, 'shop/product/detail.html', {'product': product, 'cart_product_form': cart_product_form})

修改后的视图函数就把表单对象传到了模板中,再来修改shop/templates/shop/product/detail.html模板增加相关内容:

{#在 <p class="price">${{ product.price }}</p> 这行后追加#}
    <form action="{% url 'cart:cart_add' product.id %}" method="post">
        {{ cart_product_form }}
        {% csrf_token %}
        <input type="submit" value="Add to cart">
    </form>

然后启动站点来实验一下相关功能,可以看到商品详情页内增加了新的功能:

点击增加到购物车之后,进入了购物车界面:

修改购物车中商品的数量

读者有过电商网站购物经验的话,就会知道我们的购物车很明显缺少一个功能,就是购物车页面修改商品的数量。这种修改与将商品加入购物车不同,修改的结果直接就是商品的最终数量。所以Cart.add方法里也提供了覆盖原来数量的功能。

需要实现这个功能,只需要在购物车详情视图和模板中做一个小修改即可,先来编辑cart应用里的cart_detail视图:

def cart_detail(request):
    cart = Cart(request)
    for item in cart:
        item['update_quantity_form'] = CartAddProductForm(initial={'quantity': item['quantity'], 'update': True})
    return render(request, 'cart/detail.html', {'cart': cart})

这个视图在每次进入购物车详情页面的时候,给所有的商品添加了一个键值对,值是initial里的数据初始化的CartAddProductForm实例。

为什么要这么做呢,其实就是为了区分当前的修改是来自购物车详情页面的,这里的修改都需要覆盖原数量。

回忆一下操作的流程。CartAddProductForm的update被我们以隐藏的形式埋在了商品详情页面里。其默认为空,提交给add方法的update参数就是空。所以在送到add方法里的时候,会将当前数量加上去。那么我们只需要在进入购物车详情页的时候,修改表单里的update字段的值为True就可以了。

然后修改网页上展示的那个quantity的部分,打开cart/templates/cart/detaii.html:

{#修改<td>{{ item.quantity }}</td>这一行#}
<td>
    <form action="{% url 'cart:cart_add' product.id %}" method="post">
        {{ item.update_quantity_form.quantity }}
        {{ item.update_quantity_form.update }}
        <input type="submit" value="Update">
        {% csrf_token %}
    </form>
</td>

修改之后,就是将原来的数字部分改成了一个表单,表单的数据就是用视图函数中初始化的表单类的数据填充的,这样隐藏的update字段的值变成了True,只要是从这个页面提交给cart_add视图的表单,都会覆盖原来数量。

之后启动站点,到购物车详情页来,可以看到如下所示:

为购物车构建上下文处理器

所谓上下文,就是程序的外部变量集合。我们注意到这个时候还有一个地方的功能没有实现,那就是网站首页的购物车那一栏,依然没有变化。每个网站都会有一些可能需要在每个页面都使用的数据,这些东西就类似于全局变量。就像在前几章在模板里使用过的request.user,request.messages以及本章的request.session,并不需要我们给模板传入request对象和user对象等,直接在所有的视图和模板中都可以使用。

回忆一下前博客项目对base.html显示最多评论的文章是使用了自定义的template tag;社交网站的base.html的顶部,为了知道当前的内容以便应用CSS样式,每个视图都传了一个section变量。这都是一种技巧。更通用的做法就像社交网站base.html的消息部分,直接采用request.messages变量。

django中的上下文管理器,就是能够接受一个request请求对象作为参数,然后给request对象附加一个键值对的函数。当默认启动一个项目的时候,settings.py中的 TEMPLATES 设置中的conetext_processors部分,就是给模板附加上下文的上下文管理器,有这么几个:

  • django.template.context_processors.debug:这个上下文管理器附加了布尔类型的debug变量,以及sql_queries变量,表示请求中执行的SQL查询
  • django.template.context_processors.request:这个上下文管理器设置了request变量
  • django.contrib.auth.context_processors.auth:这个上下文管理器设置了user变量
  • django.contrib.messages.context_processors.messages:这个上下文管理器设置了messages变量

除此之外,django还启用了django.template.context_processors.csrf来防止跨站请求攻击。这个组件没有写在settings.py里,但是是强制启用的,无法进行设置和关闭。有关所有上下文管理器的详情请参见官方文档

现在我们就来设置一个自定义上下文管理器,原来每个视图都将cart变量传递给模板,以后就可以在所有模板内直接使用cart变量。

在cart应用内新建一个context_processors.py文件,同视图,模板以及其他内容一样,django内的程序可以写在应用内的任何地方,但为了结构良好,还是分离出各个组件比较好:

from .cart import Cart
def cart(request):
    return {'mycart': Cart(request)}

有点简单是不是,django规定的上下文处理器,就是一个函数,接受request请求作为参数,然后返回一个键值对。键的名称就是未来在模板中可以使用的request.XXX名称。

之后在settings.py里将我们的自定义上下文管理器加到TEMPLATES设置中:

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')]
        ,
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                ......
                'cart.context_processors.cart'
            ],
        },
    },
]

定义了上下文管理器之后,只要一个模板被渲染,上下文管理器就会执行然后附加上变量名。值得注意的是,上下文管理器并不是必须的。如果上下文的内容涉及到数据库查询等比较耗时的操作,或者上下文管理器中的变量,并不是所有页面都需要,则一般情况下首先考虑的是使用自定义模板变量。否则会极大的影响网站的效率。

有了mycart变量之后,由于只是给request附加了初始化的Cart实例,所以对其他视图没有任何影响,base.html由于没有视图直接控制,就使用上下文变量比较方便,修改base.html:

{#修改<div class="cart">的内容#}

<div class="cart">
    {% with total_items=mycart|length %}
        {% if mycart|length > 0 %}
            Your cart:
            <a href="{% url 'cart:cart_detail' %}">{{ total_items }} items{{ total_items|pluralize }},
            ${{ mycart.get_total_price }}
            </a>
        {% else %}
            Your cart is empty.
        {% endif %}
    {% endwith %}
</div>

启动站点,可以发现现在购物车栏正常工作了:

这里需要说明一下的是:原书的上下文管理器的代码是:

from .cart import Cart
def cart(request):
    return {'cart': Cart(request)}

由于所有的用到购物车界面的模板都继承了base.html,视图函数都传入了名叫cart的变量,base.html里也使用cart名称的变量,作者在这里把全局变量和传入的变量设置成一样,没有很好的体现出区分效果。故将上下文管理器和base.html中的变量名cart都修改为mycart,以体现出mycart和cart是两个来自于不同地方的变量。

用户订单功能

在刚才的购物车详情页面中,有一个Checkout 结账功能,目前还无法使用。现在要来制作用户订单功能。

在每个购物车生成订单的时候,需要把订单的数据保存到数据库中。一个订单应该包含用户以及要购买的商品等信息。

建立一个新的应用orders并在settings.py里配置好。我们将用这个应用来处理订单相关的功能。

建立订单数据模型

首先还是思考一下,在点击Checkout按钮的时候,购物车页面上有什么数据?主要的数据就是cart变量代表的购物车数据对象。光有商品还不够,肯定还需要知道当前的用户是谁。其实还有一个重要的数据就是该订单是否已经支付。Order模型就是围绕这些数据建立的,编辑models.py:

from django.db import models
from shop.models import Product


class Order(models.Model):
    first_name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()
    address = models.CharField(max_length=250)
    postal_code = models.CharField(max_length=20)
    city = models.CharField(max_length=100)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
    paid = models.BooleanField(default=False)

    class Meta:
        ordering = ('-created',)

    def __str__(self):
        return 'Order {}'.format(self.id)

    def get_total_cost(self):
        return sum(item.get_cost() for item in self.items.all())


class OrderItem(models.Model):
    order = models.ForeignKey(Order, related_name='items', on_delete=models.CASCADE)
    product = models.ForeignKey(Product, related_name='order_items', on_delete=models.CASCADE)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    quantity = models.PositiveIntegerField(default=1)

    def __str__(self):
        return '{}'.format(self.id)

    def get_cost(self):
        return self.price * self.quantity

建立了两个模型Order和OrderItem,Order模型用来存储订单号,和订单相关的用户信息,以及一个是否支付的布尔字段。稍后将在支付系统中将使用该字段。还定义了一个获得总金额的方法,以便方便的知道订单的总额以便支付。

OrderItem两个外键分别连接到商品表和订单表,然后还存储了生成订单时候的价格和数量。由于价格是会变化的,所以在订单中只存储生成订单时候的价格用于计算总付款金额。然后定义了一个get_cost方法。这个类看起来类似多对多关系的的中间表,但实际上发挥的是一对一的功能。因为无需再到Product中去查询金额。

执行过makemigrations和migrate之后,将这两个类加入到管理后台中,编辑orders/admin.py:

from django.contrib import admin
from .models import Order, OrderItem


class OrderItemInline(admin.TabularInline):
    model = OrderItem
    raw_id_fields = ['product']


@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ['id', 'first_name', 'last_name', 'email',
                    'address', 'postal_code', 'city', 'paid',
                    'created', 'updated']
    list_filter = ['paid', 'created', 'updated']
    inlines = [OrderItemInline]

这次注册模型又引入了一些新的形式和字段。对OrderItem类继承了admin.TabularInline类,然后在OrderAdmin类中使用了inlines参数指定了OrderItemInline类。意思是将OrderItem类以内联(行显示)的状态包含在Order类中,显示在同一页。

这种注册模型的方式仅影响展示形式,不会影响models.py中定义的两个类之间的关系。

启动站点到 http://127.0.0.1:8000/admin/orders/order/add/ 可以看到如下的页面,更好的展示了订单与其中包含的商品之间的关系:

建立订单视图和模板

用过电商网站的读者应该知道,比如京东从购物车页面点击“去结算”按钮,会进入到一个结算页,供用户填写信息,选择地址,支付方式和配送方式等。这个页面实际上也是填写一个表单,再点提交才会真正生成订单让你付款。

类似的,我们生成一个新订单有如下步骤:

  1. 给用户提供一个表单供填写
  2. 根据用户填写的内容生成一个新Order类实例,然后将Cart中的商品放入OrderItem实例中并与Order实例建立外键关系
  3. 清理全部购物车内容,然后重定向用户到一个成功的页面或者进行其他动作。

首先就利用内置表单功能建立订单表单,在orders应用中新建forms.py:

from django import forms
from .models import Order


class OrderCreateForm(forms.ModelForm):
    class Meta:
        model = Order
        fields = ['first_name', 'last_name', 'email', 'address', 'postal_code', 'city']

采用内置的方法建立了包含这些字段的表单,现在针对这个表单要建立视图来控制表单,编辑orders应用中的views.py:

from django.shortcuts import render
from .models import OrderItem
from .forms import OrderCreateForm
from cart.cart import Cart


def order_create(request):
    cart = Cart(request)
    if request.method == "POST":
        form = OrderCreateForm(request.POST)
        # 表单验证通过就对购物车内每一条记录生成OrderItem中对应的一条记录
        if form.is_valid():
            order = form.save()
            for item in cart:
                OrderItem.objects.create(order=order, product=item['product'], price=item['price'],
                                         quantity=item['quantity'])
            # 成功生成OrderItem之后清除购物车
            cart.clear()
            return render(request, 'orders/order/created.html', {'order': order})

    else:
        form = OrderCreateForm()
    return render(request, 'orders/order/create.html', {'cart': cart, 'form': form})

整体的逻辑是,如果是POST请求而且表单验证通过,对购物车里每一个商品都在OrderItem表里生成对应的一行记录,之后删除购物车,并且不再像模板内cart变量了。(当然我们有上下文管理器,还是可以用)。

如果是POST请求但表单验证失败,返回原来表单数据,如果是GET请求,返回空白表单及购物车数据。注意,这里两个分支渲染的是两个不同的页面 create.html和 created.html。

视图有了下一步是配路由,先在orders应用里建立urls.py作为二级路由:

from django.urls import path
from . import views

app_name = 'orders'

urlpatterns = [
    path('create/', views.order_create, name='order_create'),
]

再配置主路由myshop/urls.py:

    path('orders/',include('orders.urls', namespace='orders')),

注意这一行也要在转发到shop.urls那一行之前。

既然现在我们有了视图函数,那就可以修改购物车详情页cart\templates\cart\detail.html的CHECKOUT按钮的链接了:

<a href="{% url 'orders:order_create' %}" class="button">Checkout</a>

最后是来建立模板。在orders应用下建立templates/orders/order目录,然后在order目录内建立create.html和created.html,先来编辑create.html:

{% extends 'shop/base.html' %}

{% block title %}
Checkout
{% endblock %}

{% block content %}
    <h1>Checkout</h1>

    <div class="order-info">
        <h3>Your order</h3>
        <ul>
            {% for item in cart %}
            <li>
                {{ item.quantity }} x {{ item.product.name }}
                <span>${{ item.total_price }}</span>
            </li>
            {% endfor %}
        </ul>
        <p>Total: ${{ cart.get_total_price }}</p>
    </div>

    <form action="." method="post" class="order-form" novalidate>
        {{ form.as_p }}
        <p><input type="submit" value="Place order"></p>
        {% csrf_token %}
    </form>
{% endblock %}

模板内容比较简单,展示购物车内的商品和总价,之后下边提供空白表单供填写。再来编辑created.html

{% extends 'shop/base.html' %}

{% block title %}
Thank you
{% endblock %}

{% block content %}
    <h1>Thank you</h1>
    <p>Your order has been successfully completed. Your order number is <strong>{{ order.id }}</strong>.</p>
{% endblock %}

created.html就是一个成功页面。现在运行站点,加入一些商品到购物车中,然后点击checkout,可以看到如下页面:

填写表单然后点击Place order,可以看到成功页面:

至此就做完了订单功能。

使用Celery异步发送订单邮件给用户

如果读者有过在Amazon上购物的经历,可以知道很多电商网站在用户成功下订单后,会发送一封电子邮件到用户的邮箱,内容一般是告诉用户订单的内容。

为了发送邮件,先来了解一下异步的程序。我们在视图函数中所作的所有事情,都会影响视图函数的响应时间,在视图函数完成操作之前,某些URL或者网站内容可能不可用。对于比较耗时间的操作,这样可能会导致请求失败,或者需要用户重试。举个例子:很多视频分享网站允许用户上传视频,上传之后,服务器还需要花时间进行转码,如果在这个时候用户访问该视频,显然不可能允许用户直接访问资源地址,会导致访问失败。常用的做法是设置一个重试功能,在转码完成之前用户想要查看自己上传的视频,就返回一个请求说该视频尚未处理完毕。

如果我们在视图中调用发送邮件的程序,SMTP服务器很可能会连接失败或者花费比较长的时间,如果直接在视图函数内编写,则视图函数会阻塞到邮件发送完毕才会执行下一步程序。会导致页面的响应非常慢。如果能让发邮件和返回页面响应异步的进行,那么页面的响应速度就可以提高很多。读者可以回忆一下,我们在社交网站的用户验证部分建立了发送邮件重置密码的功能,点击发送到返回成功发送的速度是不是相比其他页面要慢很多。

Celery是一个分布式任务队列,采取异步的方式同时执行大量的操作,支持实施操作和计划任务,可以方便的批量创建异步任务并且执行。Celery的文档在http://docs.celeryproject.org/en/latest/index.html

安装Celery

在django 中使用 Celery 需要在Python 中安装Celery模块:

pip install celery==4.1.0

作者推荐的是4.1版,在本文发布的时候celery最新版为4.2.1,之前已经体验过了taggit的低版本和Django 2.1冲突的问题,所以既然安装了Django 2.1,笔者没有按照作者推荐,而是安装celery的最新版。如果发现问题,踩坑捉虫的过程也是很有意思的。

Celery需要一个消息代理程序来处理外界的请求,这个代理把要处理的请求发送到Celery worker,也就是实际处理任务的模块。所以还需要安装一个消息代理程序:

安装RabbitMQ

Celery的消息代理程序有很多选择,Redis数据库也可以作为Celery的消息代理程序。这里我们使用RabbitMQ,因为它是Celery官方推荐的消息代理程序。

关于Celery,RabbitMQ以及Redis的相关内容,可以看这里的一篇文章。

RabbitMQ的下载地址:https://www.rabbitmq.com/download.html

Linux下安装RabbitMQ:

apt-get install rabbitmq

之后启动服务:

rabbitmq-server

之后会看到:

Starting broker... completed with 10 plugins.

就说明RabbitMQ已经就绪,等待接受消息。

Windows 下安装 RabbitMQ,必须先安装Erlong OPT平台,然后安装从官网下载回来的RabbitMQ windows installer。

然后把Erlong安装目录下的bin目录和RabbitMQ安装目录下的sbin目录设置到PATH中。之后安装参见这里

将Celery 加入到项目中

确定好了本机的RabbitMQ服务正常运行之后,需要在django 中使用celery,在myshop/myshop/目录下新建一个文件celery.py:

import os
from celery import Celery


# 导入django的环境
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myshop.settings')


app = Celery('myshop')

app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

这里的程序主要是和Celery相关,而不是django,解释如下:

  • 首先导入django 的环境变量,为Celery命令行创造环境。如果读者使用Pycharm开发django项目,Pycharm中启动Python console,实际上已经默认执行了该语句,作用就是将当前命令行环境配置成django项目。
  • 然后实例化了一个Celery对象
  • 调用Celery对象的.config_from_object方法,从我们的设置文件中读取设置。Celery除了可以通过实例化简单使用外,还支持设置文件进行设置,从而比较健壮的使用Celery。同时提供了一个命名为CELERY,说明我们的设置文件里,所有和CELERY相关的设置都以”CELERY”开头。
  • 最后我们调用.autodiscover_tasks(),让Celery自动发现所有的异步任务。Celery会在每个INSTALLED_APPS中列出的应用中寻找task.py文件,在里边寻找定义好的异步任务然后执行。

还需要在项目的__init__.py文件中导入celery模块,以让django运行的时候celery就运行,编辑myshop/__inti__.py:

import celery

from .celery import app as celery_app

CELERY_ALWAYS_EAGER设置可以让celery在本地以同步的方式直接执行任务,而不会去把任务加到队列中。这常用来进行测试或者检查Celery的配置是否正确。

之后就可以来编辑异步任务了。

编写需要异步处理的任务

我们准备在用户提交订单的时候异步发送邮件。一般的做法是在应用目录下建立一个模块(task.py)用于专门的异步任务,在orders应用下建立task.py:

from celery import task
from django.core.mail import send_mail
from .models import Order


@task
def order_created(order_id):
    """
    当一个订单创建完成后发送邮件通知给用户
    :param order_id: 订单编号
    :return: mail_sent
    """

    order = Order.objects.get(id=order_id)
    subject = 'Order {}'.format(order.id)
    message = 'Dear {},\n\nYou have successfully placed an order. Your order id is {}.'.format(order.first_name,
                                                                                               order_id)
    mail_sent = send_mail(subject, message, 'lee0709@vip.sina.com', [order.email])
    print(mail_sent, type(mail_sent))
    return mail_sent

使用celery模块中的@task 装饰器装饰想成为异步任务的函数。推荐只给异步函数传入ID,让其去检索数据库。拼接好标题和正文后发送邮件。

在之前已经学习过如何发送邮件,如果没有SMTP服务器,在settings.py里将邮件配置为打印到控制台上:

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

这里是将发送邮件这类耗时比较大的功能配置到为异步功能,在实际应用中,除了耗时比较大的功能之外,还可以将其他容易失败需要重试的功能,无论耗时长短,都可以设置为异步任务。

设置好了异步任务之后,还需要修改原来的视图order_created,以便在订单完成的时候,调用该异步函数。修改后的视图如下:

from django.shortcuts import render
from .models import OrderItem
from .forms import OrderCreateForm
from cart.cart import Cart
from .task import order_created


def order_create(request):
    cart = Cart(request)
    if request.method == "POST":
        form = OrderCreateForm(request.POST)
        # 表单验证通过就对购物车内每一条记录生成OrderItem中对应的一条记录
        if form.is_valid():
            order = form.save()
            for item in cart:
                OrderItem.objects.create(order=order, product=item['product'], price=item['price'],
                                         quantity=item['quantity'])
            # 成功生成OrderItem之后清除购物车
            cart.clear()
            # 成功完成订单后调用异步任务发送邮件
            order_created.delay(order.id)
            return render(request, 'orders/order/created.html', {'order': order})

    else:
        form = OrderCreateForm()
    return render(request, 'orders/order/create.html', {'cart': cart, 'form': form})

被装饰过的异步函数对象有一个delay方法,将order.id作为参数,order.id就会被传给我们自行编写的函数。当每次有用户完成订单的时候,这个异步的函数对象就会被加入到队列,然后等着被worker执行。

启动另外一个shell(必须是导入了当前环境的命令行窗口,比如Pycharm中启动的terminal),启动Celery worker,让工人等待干活:

celery -A myshop worker -l info

之后可以看到类似下边的输出,表示Celery worker 已经就绪:

[2018-09-28 20:51:13,985: INFO/MainProcess] Connected to amqp://guest:**@127.0.0.1:5672//
[2018-09-28 20:51:13,999: INFO/MainProcess] mingle: searching for neighbors
[2018-09-28 20:51:15,040: INFO/MainProcess] mingle: all alone
[2018-09-28 20:51:15,086: INFO/MainProcess] pidbox: Connected to amqp://guest:**@127.0.0.1:5672//.
[2018-09-28 20:42:04,641: INFO/SpawnPoolWorker-6] child process 16896 calling self.run()
[2018-09-28 20:42:04,641: INFO/SpawnPoolWorker-1] child process 15652 calling self.run()
[2018-09-28 20:42:04,673: INFO/SpawnPoolWorker-8] child process 18224 calling self.run()
[2018-09-28 20:42:05,225: WARNING/MainProcess] django.py:200: UserWarning: Using settings.DEBUG leads to a memory leak, never use this setting in p
roduction environments!
  warnings.warn('Using settings.DEBUG leads to a memory leak, never '
[2018-09-28 20:42:05,227: INFO/MainProcess] celery@Notebook ready.

可以看到Celery默认去连接了RabbitMQ的服务,然后显示使用guest:账号连接成功;之后提醒我们在生产环境下不要使用DEBUG,会导致内存泄露。

现在我们启动django站点,然后去实际提交一个订单。看到terminal窗口中出现:

[2018-09-28 20:51:37,255: INFO/MainProcess] Received task: orders.task.order_created[d9734042-2423-45cc-8109-a901f938bec4]
[2018-09-28 20:51:42,895: WARNING/MainProcess] 1
[2018-09-28 20:51:42,896: WARNING/MainProcess] <class 'int'>
[2018-09-28 20:51:42,896: INFO/MainProcess] Task orders.task.order_created[d9734042-2423-45cc-8109-a901f938bec4] succeeded in 5.64100000000326s: 1

检查邮件发现成功的完成了任务。如果注意看RabbitMQ的监控窗口,可以看到RabbitMQ的DeliverMessage会出现我们的任务,Queues中可以看到有一个Celery的运行中队列

注意,在发送邮件的时候,有可能出现错误信息如下:

not enough values to unpack (expected 3, got 0)

这是因为Celery 4.x 在win10版本下运行都有这个问题,解决方案为:先安装Python的 eventlet模块:

pip install eventlet

然后在启动Celery worker的时候,加上参数 -P eventlet,命令行如下:

celery -A myshop worker -l info -P eventlet

即可解决该错误。在linux下应该不会发生该错误。参考Celery项目在 Github 上的问题:Unable to run tasks under Windows #4081

使用Flower图形化模块监控Celery

如果想要监控异步任务的执行情况,可以安装Python 的FLower模块:

pip install flower==0.9.2

Flower库好像是本书中第二个从作者成书到翻译之间没有更新版本的库。Flower的项目地址是https://flower.readthedocs.io/

之后在新的终端窗口(如果是Pycharm,点terminal左上角的+)输入:

celery -A myshop flower

之后在浏览器中打开http://localhost:5555/dashboard,即可看到图形化监控的Celery情况:

总结

这一章里主要完成了四个任务,分别是建立基础的商品品类和明细展示页面,通过session建立购物车系统,建立提交订单系统,以及以发送邮件为例子介绍了在django 中使用异步任务。

主要的新知识有:

  • 为字段建立联合索引,使用meta类的index_together属性
  • 注册管理后台的list_edit属性,可以直接在列表页修改字段
  • session模块的中间件与类似字典方式的使用
  • 自定义类管理购物车和类似的小型数据
  • 建立自定义上下文处理器,为项目内所有表单增加全局变量
  • 上下文管理器与自定义template tag的取舍
  • 注册管理后台的Inline模式,外键关联的表格显示在一页内的做法
  • Celery与RabbitMQ在linux和windows下的配置
  • Django内使用Celery的方法与异步功能的编写
  • Win10中Celery运行错误的解决方法
  • Flower模块图形化监控Celery状态