本文设计和实现购物车API, 是对之前知识的巩固和练习.
设计API
对于购物车, 我们可能需设计以下API:
- 创建购物车
- 添加商品到购物车
- 修改购物车里商品的数量
- 从购物车删除商品
- 获取购物车里所有的商品清单
- 删除购物车.
我们拿出一张纸设计一下, 简单设计包括请求方法, 请求 url, 请求体及响应.
首先是购物车. 注意创建购物车无需提供用户或顾客ID, 所以POST请求体为空.
然后购物车清单条目:
所以 API端点就是 4个, 设计两个视图集来实现:
在开始编码实现API之前, 我们先修订下购物车数据模型.
修改购物车模型
Cart模型目前只有一个created_at字段, 我们知道, Django会自动生成一个主键ID, 但这个主键是数字型, 1,2,3,4…, 黑客很容易猜到这个ID, 最好将它改成GUID. GUID是32位的一串字符, 黑客是很难猜到的. 注意UUID加上primary_key=True, 表示这就是主键,这样Django就不会再生成主键, 另外default=uuid4 是引用, 不是函数调用, 如果写成 default = uuid4() , 就会成为硬编码导致默认的id都同一个值了.
from uuid import uuid4
…
class Cart(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4)
created_at = models.DateTimeField(auto_now_add=True)
改成GUID后, 存储 Cart的主键的字节数由原先的数值8个字节 增加到 32个字节, CartItem表也同样增加, 但这点空间在现在不算什么事. 如果一定要节省空间, 可以在Cart表添加GUID字段, 保留原先的主键字段, 这样在CartItem表则用之前的数字主键, 但会降低性能, 因为相关查询等操作需要在数字主键与GUID中进行转换. 这里我们在CartItem的外键也使用GUID.
另外, 我们对CartItem也作一些小修改, 一是为方便理解设置 cart的related_name为items, 二是增加二个字段(cart和product)的联合唯一, 不能出现cart和product一样的多条记录.
class CartItem(models.Model):
cart = models.ForeignKey(
Cart, on_delete=models.CASCADE, related_name=’items’)
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.PositiveSmallIntegerField()
class Meta:
unique_together = [[‘cart’, ‘product’]]
再进行迁移:
python manage.py makemigrations
python manage.py migrate
创建购物车
三个工作, 序列化类, 视图类以及 URL映射.
先看序列化类store– serializers.py, 我们只留id字段, 并设成只读:
class CartSerializer(serializers.ModelSerializer):
class Meta:
model = Cart
fields = [‘id’]
id = serializers.UUIDField(read_only=True)
再看视图类。store–views.py, 我们只使用添加操作, 就不用ModelViewSet,只继承CreateModelMinxin 和 通用视图集GenericViewSet.
class CartViewSet(CreateModelMixin, viewsets.GenericViewSet):
serializer_class = CartSerializer
queryset = Cart.objects.all()
再添加 url 映射:
router.register(“carts”, views.CartViewSet)
保测测试, POST一个空JSON对象, 会自动生成一个Cart对象.
获取一个购物车信息
获取购物车清单, 给出请求…/carts/<guid> ,返回如下格式的清单.
先修改序列类, 在CartSerializer中添加字段 items. 该字段是用了子序列类CartItemSerializer, 注意加上提供参数 many=True, 表示是一个多个对象的查询集
class CartItemSerializer(serializers.ModelSerializer):
class Meta:
model = CartItem
fields = [‘id’, ‘product’, ‘quantity’]
class CartSerializer(serializers.ModelSerializer):
id = serializers.UUIDField(read_only=True)
items = CartItemSerializer(many=True)
class Meta:
model = Cart
fields = [‘id’, ‘items’]
添加CartViewSet, 只使用获取单个对象的通用视图RetrieveModelMinxin.
class CartViewSet(RetrieveModelMixin, viewsets.GenericViewSet):
serializer_class = CartSerializer
queryset = Cart.objects.all()
再添加 url 映射:
router.register(“carts”, views.CartViewSet)
对于购物车清单的Product, 显示是主键, 我们肯定希望显示名称和价格, 修改CartItemSerializer 的字段 product为 子序列类. 如果使用ProductSerializer类, 将会显示所有字段, 这里我们只需title 和 unit_price , 所以添加一个SimpleProductSerializer序列类:
class SimpleProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = [‘id’, ‘title’, ‘unit_price’]
class CartItemSerializer(serializers.ModelSerializer):
product = SimpleProductSerializer()
…
现在添加购物车每个商品的的总价, 也就是单价乘以数量, 添加字段 total_price.
class CartItemSerializer(serializers.ModelSerializer):
…
total_price = serializers.SerializerMethodField()
def get_total_price(self, cartitem: CartItem):
return cartitem.quantity * cartitem.product.unit_price
class Meta:
model = CartItem
fields = […, ‘total_price’]
我们在购物车也添加总价 total_price:
class CartSerializer(serializers.ModelSerializer):
…
total_price = serializers.SerializerMethodField()
def get_total_price(self, cart: Cart):
return sum([item.product.unit_price*item.quantity for item in cart.items.all()])
class Meta:
model = Cart
fields = […, ‘total_price’]
我们加总时用到了 列表推导, 如果不懂列表推导的话, 请查看《完全掌握Python》系列. 另外请注意 cart.items返回的是管理器, 需要加上all()
大部分完成了, 但我们查看Django网页调试工具, 发现多产生了很多SQL语句, 这是由于延迟查询引起, 我们修改 views 使之先期获取prefetch_related, 免得影响性能.
class CartViewSet(RetrieveModelMixin, viewsets.GenericViewSet):
…
queryset = Cart.objects.prefetch_related(‘items__product’)
这里prefetch_related参数的items__product 表示预加载 items以及每条item对应的的product.
删除购物车
非常简单,只需让CartViewSet继承加上 DestroyModelMixin 即可.
class CartViewSet(…,DestroyModelMixin, viewsets.GenericViewSet):
…
顺便修复前面获取购物车清单的一个疏忽, 让 items 字段为 只读, 否则新建购物车会出错.
class CartSerializer(serializers.ModelSerializer):
…
items = CartItemSerializer(…, read_only=True)
获取购物车清单
我们希望的结果是:
../carts/(GUID)/items , 可以获得购物车的商品清单;
../carts/(GUID)/items/(id), 可以得到具体的商品及数量
实现也比较简单, 首先添加 CartItemViewSet类, 其中queryset需要筛选到特定的购物车主键 cart_pk
class CartItemViewSet(viewsets.ModelViewSet):
serializer_class = CartItemSerializer
def get_queryset(self):
return CartItem.objects.filter(cart=self.kwargs[‘cart_pk’])
然后添加url映射, 仿照之前的 ReviewViewSet, 在store–urls.py里添加:
…
carts_router = routers.NestedDefaultRouter(router, ‘carts’, lookup=’cart’)
carts_router.register(‘items’,views.CartItemViewSet,basename=’cart-items’)
urlpatterns = router.urls + products_router.urls + carts_router.urls
保存即可. 刷新网页, 功能是完成的. 但查看调试工具, 能看到每一个商品都要去调一次数据库的SQL执行, 我们预先加载优化下: 修改 CartItemViewSet类的get_queryset方法, 先加载product对象
def get_queryset(self):
return CartItem.objects\
.filter(cart=self.kwargs[‘cart_pk’])\
.select_related(‘product’)
添加商品到购物车
根据我们之前的设计, 添加到购买车一般只需要POST类似这样的JSON就可以了
{
product_id: XXX,
quantity: XXX
}
但目前CartItemSerializer 包括了Product关系的明细, 如果还是以这个为序列化类的话, 会很麻烦. 我们可以选择再创建一个序列化类,比如命名为 AddCartItemSerializer, 然后简化相关字段.
class AddCartItemSerializer(serializers.ModelSerializer):
product_id = serializers.IntegerField()
class Meta:
model = CartItem
fields = [‘id’, ‘product_id’, ‘quantity’]
然后在 视图中进行判断, 如果方法是POST的话 就用AddCartItemSerializer. 如下:
class CartItemViewSet(viewsets.ModelViewSet):
def get_serializer_class(self):
if self.request.method == ‘POST’:
return AddCartItemSerializer
return CartItemSerializer
…
添加商品到购物车, 不能能简单用默认的序列类的实现. 因为如果有用户添加的商品在原购物车已有的话, 只需加上数量, 而不是添加一条记录. 我们重载save方法.
class AddCartItemSerializer(serializers.ModelSerializer):
…
def save(self, **kwargs):
product_id = self.validated_data[‘product_id’]
quantity = self.validated_data[‘quantity’]
cart_id = self.context[‘cart_id’]
try:
cart_item = CartItem.objects.get(
cart_id=cart_id, product_id=product_id)
cart_item.quantity += quantity
cart_item.save()
self.instance = cart_item
except CartItem.DoesNotExist:
self.instance = CartItem.objects.create(
cart_id=cart_id, **self.validated_data)
return self.instance
我们判断商品是否已同一购物车里, 如果存在的话, 就增加数量. 如果不存在, 就抛出异常来执行添加记录. 返回 instance 是查看父类的save方法需要返回instance, 所以也作相应实现.
另外, cart_id 需要从上下文上获取, 我们需要修改 视图类, 获取序列的上下文.在store–views.py中, 添加 CartItemViewSet类的 get_serializer_context方法
class CartItemViewSet(viewsets.ModelViewSet):
…
def get_serializer_context(self):
return {“cart_id”: self.kwargs[‘cart_pk’]}
快完成了, 目前我们没有验证 POST的数据的有效性, 比如有人传递给我们的json 体的 {product_id: 0 ,…} 的话, 将会出错, 因为我们找不到 product_id为 0 的商品, 我们来添加有效性检.
之前我们谈到可以对Serializer进行数据验证, 用 validate 进行整个对象的验证, 也可以单独对某个字段进行验证. 方法也很简单, 以“validate_ ”开头加字段名即可.
class AddCartItemSerializer(serializers.ModelSerializer):
product_id = serializers.IntegerField()
def validate_product_id(self, value):
if not Product.objects.filter(pk=value).exists():
raise serializers.ValidationError(
‘No product with the given ID was found.’)
return value
我们再试下输入product_id为0时, 就会提示没找到 . 再来看下quantity, 如果输入 -1 的话, 也会提示, 因为CartItem的模型设了quantity类型PositiveSmallIntegerField 为非负数 , 我们可提升下, 使之大于0 .
class CartItem(models.Model):
…
quantity = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)])
…
更新购物车商品数量
更新我们也需要自定义一个序列类, 因为只需要quantity字段.
class UpdateCartItemSerializer(serializers.ModelSerializer):
class Meta:
model = CartItem
fields = [‘quantity’]
然后, 在视图类进行判断HTTP方法, 选择不同的序列类.
class CartItemViewSet(viewsets.ModelViewSet):
http_method_names = [‘get’, ‘post’, ‘patch’, ‘delete’]
def get_serializer_class(self):
if self.request.method == ‘POST’:
return AddCartItemSerializer
elif self.request.method == ‘PATCH’:
return UpdateCartItemSerializer
return CartItemSerializer
如果是PATCH方法的话, 就使用UpdateCartItemSerializer .
另外, 我们用不到 PUT 方法, 所以在 http_method_names 里不包括它.
删除购物车商品
删除购物车商品前面已完成的, 因为继承于ModelViewSet, 默认已有实现的.我们可以测试下, 确认功能正常.
这里需要注意的是CartItemView类的属性 http_method_names 列出的各方法需要用小写, 如果使用了大写则是无效的.
…
http_method_names = [‘get’, ‘post’, ‘patch’, ‘delete’]
…
小结
本文设计和实现了购物车API, 是对之前知识的巩固和练习.
下一篇将转向新话题, 开始介绍Django的用户认证.