09-Task-E-A-Smarter-Cart
尽管我们已经实现了购物车的基本功能,不过我们还有许多功能需要处理。一开始,我们要识别出用户是何时添加多个相同商品至购物车的。而且,当错误发生时我们也要保证购物车可以友好地反应,以及向客户或系统管理员通知相应信息。
迭代 E1:创建更智慧的购物车
由于我们需要统计购物车中的每件商品数量,所以我们需要修改 line_items 表。不过我们已经使用过迁移了,就像 64 页的情况一样,我们已经应用过迁移更新 schema 和数据库。虽然我们只是需要修改脚手架初始的 model 部分,但基本方式也是类似的。
rails generate migration add_quantity_to_line_items quantity:integer
Rails 会根据 migration 的名字解析是向 line_items 表添加一列或者多列,最后的参数表示对应的列名字及数据类型键值对。Rails 可以匹配两种模式,分别是 add_XXX_to_TABLE 和 remove_XXX_from_TABLE,XXX 的值并不重要,重要的是在 migration 名字后的列名和数据类型键值对。
Rails 唯一无法解析到的是该列的具体默认值。在许多情况中都使用的是 null 值,不过在我们应用这个迁移之前我们需要修改它,将已经存在购物车中的商品条目数据默认值修改为 1。
class AddQuantityToLineItems < ActiveRecord::Migration
def change
add_column :line_items, :quantity, :integer, default: 1
end
end
修改完成后,我们运行迁移。
rake db:migrate
现在对于 Cart 来说,我们需要一个智慧的 add_product() 方法,它需要检测我们正在添加的商品是否已经在购物车中添加过了,如果已经添加过,它需要累加数量,如果没有添加过,就构建一个新的 LineItem。
# app/models/cart.rb
def add_product(product_id)
current_item = line_items.find_by(product_id: product_id)
if current_item
current_item.quantity += 1
else
current_item = line_items.build(product_id: product_id)
end
return current_item
end
find_by() 方法是 where() 方法的精简版。与返回数组结果不同的是,它只返回已经存在的 LineItem 或者 nil。
我们也需要修改商品条目的 controller 调用此方法。
# app/controllers/line_items_controller.rb
def create
product = Product.find(params[:product_id])
# the new line
@line_item = @cart.add_product(product.id)
respond_to do |format|
if @line_item.save
format.html { redirect_to @line_item.cart, notice: 'Line item was successfully created.' }
format.json { render :show, status: :created, location: @line_item }
else
format.html { render :new }
format.json { render json: @line_item.errors, status: :unprocessable_entity }
end
end
end
最后在 show view 中还需要进行修改,才能够展示此信息。
<!-- app/views/carts/show.html.erb -->
<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>
<h2>Your Pragmatic</h2>
<ul>
<% @cart.line_items.each do |line_item| %>
<!-- the new line -->
<li><%= line_item.quantity %> × <%= line_item.product.title %></li>
<% end %>
</ul>
现在所有的细节都已经处理好,我们要回到商店主页,再次按下已经添加至购物车的商品相应的 Add to Cart 按钮。我们希望看到的结果是,单独商品列表与一个数量为 2 的商品。这是因为我们增加了一列显示数量,不会再展示多行相同的商品数据。下面要做的就是迁移数据。
我们开始创建迁移。
rails generate migration combine_items_in_cart
这样操作时 Rails 并不能了解我们要做什么,所以我们不能依靠生成的 change() 方法。我们要做的是通过 up() 和 down() 方法分别替换。首先是使用 up() 方法:
# db/migrate/20201202131433_combine_items_in_cart.rb
class CombineItemsInCart < ActiveRecord::Migration
def up
Cart.all.each do |cart|
sums = cart.line_items.group(:product_id).sum(:quantity)
sums.each do |product_id, quantity|
if quantity > 1
cart.line_items.where(product_id: product_id).delete_all
item = cart.line_items.build(product_id: product_id)
item.quantity = quantity
item.save!
end
end
end
end
end
这是目前为止我们看过最大段的代码了。让我们分割成小段看看:
我们开始时遍历每个 cart。
每个 cart 我们都统计相关商品条目的数量,通过以 product_id 分组的方式。生成的结果 sums 将是 product_ids 和 quantity 的有序键值对。
我们遍历每个 sums,并从中提取每个 sums 的 product_id 和 quantity。
当 quantity 比 1 大时,我们将删除此购物车此商品相关的每个商品条目,并且通过当前 quantity 创建一条独立的商品条目数据进行替换。
现在 Rails 可以简单优雅地解析此算法。
使用刚才编写的代码,我们像其他迁移一样应用它。
rake db:migrate
我们立马就可以看见购物车的结果发生的变化。
尽管我们已经可以为此感到自豪,但功能还没有完全完成。迁移中有一个重要概念,就是每一步都可以反转,所以我们还要再实现一个 down() 方法。这个方法查找所有数量大于 1 的商品条目,将它们拆解成数量为 1 的多个商品条目添加至相关的 cart 和 product,最后再将此商品条目删除。下面的条码就是用于完成此功能的:
# db/migrate/20201202131433_combine_items_in_cart.rb
class CombineItemsInCart < ActiveRecord::Migration
def down
LineItem.where("quantity>1").each do |item|
item.quantity.times do
LineItem.create cart_id: item.cart.id, product_id: item.product.id, quantity: 1
end
item.destroy
end
end
end
现在我们也可以轻松地通过一条命令就将迁移回滚。
rake db:rollback
Rails 还提供了一个手动 rake 任务,它允许你检查迁移的状态。
rake db:migrate:status
现在你可以修改再重新应用迁移,甚至完全删除它。我们通过将迁移移动至其他路径下的方式看看购物车中回滚后的结果。
当你将迁移文件还原位置并再次应用它(通过 rake db:migrate
命令),我们就拥有了一个维护了每个商品统计数量的购物车,我们也有一个 view 可以展示统计结果。
很开心我们呈现了这个功能,我们向客户再次展示了早上的工作成果。她很满意,她已经能看到逐渐成型的网站了。不过她也提出了一些问题,她曾经看过一些关于相关刊物上的文章,其中有提到类似的电子商务网站频繁地受到攻击和破坏。她了解到其中有种攻击是在网站请求中携带不良参数,期望发现 bug 和完全缺陷。她也注意到购物车链接 carts/nnn 中的 nnn 是我们的内部购物车 ID。这样会被恶意攻击,她手动在浏览器中输入这个请求,并使用了一个无效的购物车 ID。当应用显示下图中的界面时她并不意外。
这让应用看起来很不专业。所以,我们下个迭代会花些时间让应用更具扩展性。
迭代 E2:处理错误
看上张图展示的界面是我们的应用在 carts controller 67 行抛出的异常。我们再看看这行代码:
@cart = Cart.find(params[:id])
如果购物车数据不能被查找到,Active Record 将抛出 RecordNotFound 异常,我们已经清楚地知道要处理的异常了。不过,问题是怎么出现的呢?
我们不能就这么忽略这个问题。从安全角度出发才是比较好的解决方式,是由于没有提供相应信息才导致这次潜在攻击的。然而,它也意味着我们在生成 cart ID 的代码中有一个 bug,但我们的应用对于外界来说是无反应的,所以没有人会知道有这个错误。
其实,当异常被抛出时我们应该采取两个动作。首先,我们要通过 Rails logger 工具将事实记录于内部日志文件中。第二,我们重新展示分类界面并向用户展示短讯(就像「购物车无效」类似),使他们可以继续使用我们的网站。
Rails 也有方便的方法处理错误和报告错误。它定义了一个称为 flash 的结构。一个 flash 就是一个 bucket(实际上更像 Hash),你可以将进行一次请求发生的情况都存储于其中。flash 的内容在被自动删除之前都可以被相同 session 的其他请求使用。简单来说,flash 就是被用来收集错误的。例如,当我们的 show() 方法发现了传递过来的无效购物车 ID,它将在 flash 区域中存储错误信息,并且重定向至 index() action 重新展示分类。index action 对应的 view 也会提取错误信息并且在分类页面展示它。在 view 中访问 flash 信息是通过 flash 访问器方法完成。
为什么我们不能将错误信息存储在任意原来的实例变量中?首先要知道,在每次重定向时都是同我们的应用向浏览器进行发送,之后浏览器再向应用发起相应的请求。此时我们接收到这个请求,我们的应用还在继续运转前行,之前请求的实例变量已经消失。而 flash 数据是顺序存储在 session 中的,它可以在不同的请求间被运用。
铺垫完了 flash 数据的背景知识后,我们要创建一个 invalid_cart() 方法在问题出现时发起报告。
# app/controllers/carts_controller.rb
class CartsController < ApplicationController
before_action :set_cart, only: [:show, :edit, :update, :destroy]
# The new line
rescue_from ActiveRecord::RecordNotFound, with: :invalid_cart
private
# The new function
def invalid_cart
logger.error "Attempt to access invalid cart #{params[:id]}"
redirect_to store_url, notice: 'Invalid cart'
end
end
rescue_from 语句将拦截由 Cart.find() 抛出的异常。在处理方法中,我们还要做下面的事情:
通过 Rails logger 记录错误。每个 controller 都有 logger 变量。这里我们用它将信息记录为 error 级别的日志。
通过 redirect_to() 方法重定向至分类展示页。:notice 参数是将存储于 flash 中的信息作为提醒。为什么是定向而不是直接展示分类页面?如果我们进行重定向,用户的浏览器就会显示商店 URL,而不是 http://.../cart/wibble。我们通过这种方式展示应用更少的信息。我们也避免了用户按下刷新按钮导致错误重新触发。
通过此处的代码,我们会向用户返回出问题的请求。现在,当我们访问下面的 URL 时:
在浏览器中我们不再会看见一堆错误。相反,显示的是分类页。如果我们看看日志文件的末尾(development.log 在 log 路径中),我们还可以看到相关的信息。
下图更友好地展示了错误。
在 Unix 机器中,我们还可以通过 tail 或 less 命令查看日志文件。在 Windows 中,你可以通过自己喜欢的编辑器查看文件。保证有一个窗口总是打开添加至日志中的新内容是一个不错的主意。在 Unix 中,你可以使用 tail -f。你也可以下载 Windows 环境的 tail 命令,或者使用一个基于用户界面的工具。最后,OS X 用户可以通过 Console.app 追踪日志文件,只要在命令行中输入 open name.log 即可。
因为我们的应用处于互联网环境,我们不能关心已经发布的网络表单,我们还需要关心每个我们提供的 HTML 之中的接口,因为一些心怀恶意的黑客会利用这些接口并向其提供的额外的参数发起请求。其实无效购物车并不是我们最大的问题,我们还要防止访问其他人的购物车。
通常,controller 是你的第一道防线。让我们继续,我们从参数列表白名单中移除了 cart_id。
# app/controllers/line_items_controller.rb
class LineItemsController < ApplicationController
# Never trust parameters from the scary internet, only allow the white list through.
def line_item_params
# The update line
params.require(:line_item).permit(:product_id)
end
end
我们运行 controller 测试可以看到结果。
rake test:controllers
虽然没有测试失败,但在 log/test.log 文件中我们可以看到有由于违反安全规约而显示的警告信息。
清理下面的测试将会使问题得到解决。
# test/controllers/line_items_controller_test.rb
test "should update line_item" do
# The update line
patch :update, id: @line_item, line_item: { product_id: @line_item.product>
assert_redirected_to line_item_path(assigns(:line_item))
end
现在我们清理掉测试日志再运行测试看看。
rake log:clear LOGS=test
rake test:controllers
最后查看一下日志文件,已经没有任何问题了。
定期复查日志文件是个好习惯,日志文件中其实有很多有用的信息。
到了这个迭代的结束,我们再次向客户展示成果,向她展示我们已经使用合适的方式对错误进行了处理。她很满意并且继续操作应用。不过在使用我们新开发的购物车显示界面时她感觉有个主要问题,她没有办法将商品条目从购物车中移出。这个重要的修改是我们下个迭代要处理的。我们要在下班前处理完。
迭代 E3:完成购物车
现在我们知道要实现「empty cart」功能需要向给购物车添加一个链接,并且在 carts controller 中修改 destroy() 方法将 session 清除。
让我们从模板开始,并且通过 button_to() 方法在页面增加一个按钮。
<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>
<h2>Your Pragmatic</h2>
<ul>
<% @cart.line_items.each do |line_item| %>
<li><%= line_item.quantity %> × <%= line_item.product.title %></li>
<% end %>
</ul>
<!-- The new line -->
<%= button_to 'Empty cart', @cart, method: :delete, data: { confirm: 'Are you sure?' } %>
在 controller 中,我们要改造 destroy() 方法保证用户是本人希望将购物车清空(思考一下)并且将 session 中购物车移除,最后携带提示信息重定向至主页。
# app/controllers/carts_controller.rb
def destroy
@cart.destroy if @cart.id == session[:cart_id]
session[:cart_id] = nil
respond_to do |format|
format.html { redirect_to store_url, notice: 'Your cart is currently empty' }
format.json { head :no_content }
end
end
并且我们也修改了 test/controllers/carts_controller_test.rb 中相应的测试。
# test/controller/carts_controller_test.rb
test "should destroy cart" do
assert_difference('Cart.count', -1) do
session[:cart_id] = @cart.id
delete :destroy, id: @cart
end
assert_redirected_to store_path
end
现在当我们查看购物车并按下 Empty cart 按钮后,我们将回到分类页面,并且附带一条友好的提示信息如下图:
当一个商品目录被添加时,我们也可以移出自动生成的 flash 信息。
# app/controllers/line_items_controller.rb
def create
product = Product.find(params[:product_id])
@line_item = @cart.add_product(product.id)
respond_to do |format|
if @line_item.save
# The update line
format.html { redirect_to @line_item.cart }
format.json { render :show, status: :created, location: @line_item }
else
format.html { render :new }
format.json { render json: @line_item.errors, status: :unprocessable_entity }
end
end
end
最后,我们将购物车展示进行了小小的美化。我们使用表格取代了 <li>
标签。并且,我们通过 CSS 添加了样式。
<!-- app/views/carts/show.html.erb -->
<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>
<h2>Your Pragmatic</h2>
<table>
<% @cart.line_items.each do |item| %>
<tr>
<td><%= item.quantity %>×</td>
<td><%= item.product.title %></td>
<td class="item_price"><%= number_to_currency(item.total_price) %></td>
</tr>
<% end %>
<tr class="total_line">
<td colspan="2">Total</td>
<td class="total_cell"><%= number_to_currency(@cart.total_price) %></td>
</tr>
</table>
<%= button_to 'Empty cart', @cart, method: :delete, data: { confirm: 'Are you sure?' } %>
要完成这个功能,我们需要在 LineItem 和 Cart model 中分别加上计算单独一种商品条目总价和整个购物车总价的方法。首先是商品条目,只用添加几行简单的代码即可:
# app/models/line_item.rb
class LineItem < ActiveRecord::Base
belongs_to :product
belongs_to :cart
# The new function
def total_price
product.price * quantity
end
end
我们通过优雅的 Arrays::sum() 方法实现计算集合中每个条目的价格。
# app/models/cart.rb
def total_price
line_items.to_a.sum { |item| item.total_price }
end
接着我们要在 carts.css.scss 中添加一点样式。
// app/assets/stylesheets/cart.css.scss
.carts {
.item_price, .total_line {
text-align: right;
}
.total_line .total_cell {
font-weight: bold;
border-top: 1px solid #595;
}
}
就像下图一样,这是一个更好的购物车:
总结
我们的购物车现在已经得到客户的认可。一路下来,我们处理了下列的事情:
向已经存在的表中添加一列,并且附带默认值
按照新表迁移已经存在的数据
对于被发现的错误通过 flash 提示处理
使用 logger 记录事件
从白名单列表中移除一个参数
删除一条记录
调整使用表格渲染,并且使用 CSS
不过就在我们思考往这些基础功能上继续迭代时,客户发来了 《Information Technology and Golf Weekly》的拷贝。显然,这是篇关于 Ajax 风格浏览器接口的文章,其中的内容还在动态更新。嗯......,我们明天再看吧。
自习天地
下列的知识需要你自己试一试:
- 创建一个迁移,将商品价格从 products 中拷贝至 line_items 中,并且修改 Cart model 中的 add_product() 方法,当新创建一条商品条目时就获取相应的商品价格。
- 增加单元测试,测试添加唯一的商品和重复的商品。注意,你需要修改通过名称关联的 products 和 carts,比如 product: ruby。
- 检查商品和商品条目中可能会出现不友好错误提示的地方
- 添加从购物车中删除单独商品条目的功能。每行都需要相应的按钮,并且按钮应该链接至 LineItemsController 中的 destroy() 方法。