11 任务G 检出

优质
小牛编辑
131浏览
2023-12-01

让我们评估一下现状。到目前为止,我们已经开发好了基本的商品管理系统,也实现了商品分类界面,并且创建了整洁大方的购物车。所以,现在我们需要让购物者能够真正购买购物车中的商品。让我们一起来看看该如何实现。

我们打算先实现一个基础版本的。现在,我们打算获取客户的联系信息和支付信息。通过这些信息,我们便可以在数据库中构建订单。使用这种实现方式时,我们需要了解更多关于 model,验证和表单处理的知识。

迭代 G1:获取订单

订单是由一组商品条目组成,并且还携带购买时需要的相关信息。我们的购物车已经包含了 line_items,所以我们要给 line_items 表添加 order_id 字段,然后创建一个基于 59 页绘制的数据草图的 orders 表,orders 表将包含基本的客户信息。

一开始,我们要创建 order model,并且修改 line_items 表。

rails generate scaffold Order name address:text email pay_type

rails generate migration add_order_to_line_item order:references

上述命令中有三个字段我们并没有指定数据类型,这是由于不指定时数据类型默认是 string。这也是个小技巧,Rails 将许多常用的东西都简化了,只有当你要特别指定数据类型时才需要额外声明。

现在我们已经创建了 migration,并且我们还可以运行它们。

rake db:migrate

由于在 schema_migrations 表中还没有这两个新 migration 被应用过的记录,所以 db:migrate 会直接运行这两个 migration,不过我们也可以分别运行它们。

创建订单表单

现在我们已经拥有了相应的表和 model,是时候开始整理一下操作流程了。首先,我们需要给购物车添加一个「Checkout」按钮。因为这里需要创建新订单,我们通过 order controller 中的 new action 关联购买商品。

<!-- app/views/carts/_cart.html.erb -->

<h2>Your Cart</h2>
<table>
  <%= render(cart.line_items) %>

  <tr class="total_line">
    <td colspan="2">Total</td>
    <td class="total_cell"><%= number_to_currency(cart.total_price) %></td>
  </tr>
</table>

<!-- Here's new line -->
<%= button_to 'Checkout', new_order_path, method: :get %>
<%= button_to 'Empty cart', cart, method: :delete, remote: true, data: { confirm: 'Are you sure?' } %>

一开始我们想核查购物车中的商品,所以我们需要访问购物车。根据之前的计划,在创建订单时也要用到购物车。

# app/controllers/orders_controller.rb

class OrdersController < ApplicationController
  # Here's new line
  include CurrentCart

  # Here's new line
  before_action :set_start, only: [:new, :create]
  before_action :set_order, only: [:show, :edit, :update, :destroy]

  #...
end

Joe 提问:信用卡的流程又该在哪里处理?

在现实世界中,我们希望应用本身能够检查商务边界,于是我们会想将信用卡流程集成进来。不过,集成支付系统需要进行大量的文档工作和遭遇种种麻烦。它将让我们从 Rails 上分心,所以我们将会花一些时间讲一讲支付业务的详情。

我们会在 411 页回到这个话题,那时我们会讲述一下能够帮助我们的相关插件。

下面我们要编写代码对购物车进行核查。如果购物车中没有商品,将直接重定向至商店首页,并向其展示相关提示信息,接着立即 return。这样可以避免用户创建空订单。此处的 return 声明十分重要,如若不然,由于 controller 即要重定向又要渲染输出内容将引起「double render」错误。

# app/controllers/orders_controller.rb

def new
  if @cart.line_items.empty?
    redirect_to store_url, notice: 'Your cart is empty'
    return
  end
  @order = Order.new
end

接着将添加「requires item in cart」测试,然后修改「should get new」测试以保证购物车中有商品。

# test/controllers/orders_controller_test.rb

# Here's new block
test "requires item in cart" do
  get :new
  assert_redirected_to store_path
  assert_equal flash[:notice], 'Your cart is empty'
end

test "should get new" do
  # Here's new block
  item = LineItem.new
  item.build_cart
  item.product = products(:ruby)
  item.save!
  session[:cart_id] = item.cart.id
  get :new
  assert_response :success
end

现在我们想通过 new action 向用户展示相应的表单,以督促他们填写 orders 表相关信息,包括姓名、地址、邮箱和支付方式。也就是说我们要显示一个包含表单的 Rails 模板。表单中的输入框要关联 model 对象的相应属性,所以我们在 new action 中创建了一个空的 model 对象,使这些输入框能够基于它运行。

在 HTML 的表单中常用的技巧是在填写项中设置初始值,当用户点击提交按钮时将填写项的值提取至应用中。

不过在 controller 中我们已经将新的 Order model 对象赋值给 @order 实例变量。由于 view 中是直接使用此对象的值填充表单的,而它又只是一个空对象,所以所有的填写项也将是空的。不过,一般情况下我们也不会填写一个已经存在的订单。或许我们希望修改订单或者用户提交订单时验证失败需要编辑订单,这些情况下我们都希望相应的 model 数据能够显示在表单中。不过当前步骤中使用空 model 对象有利于保持一致性,可以保证在 view 中 model 对象总是可用的。然后,当用户点击提交按钮时,来自表单的数据会被提取为 model 对象并返回 controller 中。

非常幸运,Rails 已经将这一切的工作处理得十分简单。它已经提供了一组 form 辅助方法。这些辅助方法可以与 controller 和 model 交互,以实现表单业务处理的集成解决方案。在我们开始编写需要的表单前先看看一个简单的例子:

<%= form_for(@order) do |f| %>
  <p>
    <%= f.label :name %>
    <%= f.text_field :name, size: 40 %>
  </p>
<% end %>

以上代码中有两个比较有趣的事情。首先是 form_for() 辅助方法创建了一个标准的 HTML 表单。不止如此,第一个参数 @order 是告诉辅助方法命名填写项和返还 controller 分配的变量值是这个实例变量。

你可以看到 form_for 还使用了 Ruby block(直到第 6 行结束的 block)。在 block 中你可以放置常规的模板内容(比如 <p> 标签)。但你也可以通过 block 参数(当前示例中是 f)关联表单的上下文。我们在第 4 行将上下文添加给了表单的文本框。因为文本框是由 form_for 构造,所以它也会自动关联 @order 对象数据。

这些关系可能会让你有些糊涂。需要记住的要点是 Rails 是通过 namesvalues 将填写项与 model 进行关联的。在 form_for 和填写项级别的辅助方法结合使用时这些信息都会提供(比如 text_field)。我们也可以看看下图绘制的流程:

The names in form_for map to objects and attributes.png

现在我们需要修改表单模板,用来收集供核查的用户信息。因为是由 order controller 中的 new action 进行调起,所以模板便是 app/views/orders 路径下的 new.html.erb 文件。

<!-- app/views/orders/new.html.erb -->

<div class="depot_form">
  <fieldset>
    <legend>Please Enter Your Details</legend>
    <%= render 'form' %>
  </fieldset>
</div>

还有个 partial 名字叫做 _form

<!-- app/views/orders/_form.html.erb -->

<%= form_for(@order) do |f| %>
  <% if @order.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@order.errors.count, "error") %> prohibited this order from being saved:</h2>

      <ul>
      <% @order.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.label :name %><br>
    <!-- Here's new line -->
    <%= f.text_field :name, size: 40 %>
  </div>
  <div class="field">
    <%= f.label :address %><br>
    <!-- Here's new line -->
    <%= f.text_area :address rows: 3, cols: 40 %>
  </div>
  <div class="field">
    <%= f.label :email %><br>
    <!-- Here's new line -->
    <%= f.text_field :email, size: 40 %>
  </div>
  <div class="field">
    <%= f.label :pay_type %><br>
    <!-- Here's new line -->
    <%= f.select :pay_type, Order::PAYMENT_TYPES,
      prompt: 'Select a payment method' %>
  </div>
  <div class="actions">
    <!-- Here's new line -->
    <%= f.submit 'Place Order!' %>
  </div>
<% end %>

Rails 在所有不同级别的 HTML 元素中都使用了表单辅助方法。在前面的代码中,我们通过 text_filedemail_filedtext_area 方法分别获取用户的名字、邮箱和地址。在 343 页我们将更深入地探讨辅助方法。

不过有个事情还是需要注意一下,上面有段代码是用于关联下拉列表的。我们将下拉内容作为支付方式存储在 Order model 中。最好我们在 order.rb 中定义相应的数组内容。

# app/models/order.rb

class Order < ActiveRecord::Base
  PAYMENT_TYPES = [ "Check", "Credit card", "Purchase order" ]
end

在模板中,我们将支付类型选项传递给 select 辅助方法。并且还传递了 :prompt 参数,这个参数会添加一个虚拟的下拉选项作为提示。

再添加一些 CSS 样式:

// app/asserts/stylesheets/application.css.scss

.depot_form {
  fieldset {
    background: #efe;

    legend {
      color: #dfd;
      background: #141;
      font-family: sans-serif;
      padding: 0.2em 1em;
    }
  }

  form {
    label {
      width: 5em;
      float: left;
      text-align: right;
      padding-top: 0.2em;
      margin-right: 0.1em;
      display: block;
    }

    select, textarea, input {
      margin-left: 0.5em;
    }

    .submit {
      margin-left: 4em;
    }

    br {
      display: none
    }
  }
}

我们已经准备好使用的表单了。当我们在购物车中添加商品并点击「Checkout」按钮后,你将看见如下图的结果。

Our checkout screen.png

看起来还不错。在我们继续之前需要给 new action 添加一些验证。我们要在 Order model 中添加代码验证用户输入的所有表单数据。

我们还需要验证支付方式是否是我们提供的方式之一。

有些人想知道为什么我们连支付方式也要验证,毕竟支付方式的数据不正是来自下拉选项中的数据,肯定是有效数据呀。因为应用接收的数据不一定来自由应用创建好的表单,也有些用户蓄意攻击会跳过我们的表单直接提交数据,所以我们需要验证支付类型数据。如果蓄意攻击的用户使用了未知的支付方式,他们很可能会免费获取我们的商品。

# app/models/order.rb

class Order < ActiveRecord::Base
  #...

  validates :name, :address, :email, presence: true
  validates :pay_type, inclusion: PAYMENT_TYPES
end

在表单代码的顶部也已经通过 @order.errors 显示验证失败的信息。

在我们修改了验证规则后,还要修改相应的测试夹具中的数据适应这些规则。

# test/fixtures/orders.yml

# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html

one:
  name: Dave Thomas
  address: MyText
  email: dave@example.org
  pay_type: Check

two:
  name: MyString
  address: MyText
  email: MyString
  pay_type: MyString

而且,对于被创建的订单而言购物车中至少得有一件商品,所有还要修改 line items 的测试夹具数据。

如果你没有做 132 页的 Playtime 练习的话,相关的 product 和 cart 的夹具数据你也需要一并修改。

当然你也可以进行其他的修改,不过这些数据当前只是用于功能测试。要让这些测试通过,我们还需要在 model 进行相应的实现。

获取订单详情

接着我们要实现 create() action。在此方法中需要处理如下任务:

  1. 获取来自表单的数据,并将数据整合为 Order model 对象。

  2. 将购物车中的商品添加至表单中。

  3. 验证和保存订单。如果失败了,需要显示友好的提示信息并让用户修正相关问题。

  4. 如果表单顺利保存,就将购物车删除并重新显示分类页,而且将完成下单业务的提示信息进行显示。

我们首先定义由 line item 指向 order 的关系:

# app/models/line_item.rb

class LineItem < ActiveRecord::Base
  #...
  # Here's new line
  belongs_to :order

  #...
end

然后再定义由 order 指向 line item 的关系,而且当订单被删除时相关的所有商品条目也需要被删除。

# app/models/order.rb

class Order < ActiveRecord::Base
  has_many :line_items, dependent: :destroy
  #...
end

create() 方法最后的成品如下:

# TODO

def create
  @order = Order.new(order_params)
  # Here's new line
  @order.add_line_items_from_cart(@cart)

  respond_to do |format|
    if @order.save
      # Here's new line
      Cart.destroy(session[:cart_id])
      # Here's new line
      session[:cart_id] = nil
      # Here's new line
      format.html { redirect_to store_url, notice: 'Thank you for your order.' }
      format.json { render :show, status: :created, location: @order }
    else
      format.html { render :new }
      format.json { render json: @order.errors, status: :unprocessable_entity }
    end
  end
end

一开始我们就创建了一个 Order 对象,并以来自表单的数据将它初始化。接着将存储在购物车中的商品条目添加至订单中,后面我们再实现这个方法。

接着我们将订单对象存储至(以及订单中的商品条目)数据库。存储过程中订单对象会进行校验(关于这点我们稍后再说)。

当存储成功后我们还需要再做两件事。第一是将 session 中的购物车删除,为客户的下一个订单作好准备。然后通过 redirect_to() 方法展示分类页面以及下单成功的信息。如果保存失败,我们将再次显示当前购物车订单的表单。

create action 中我们假设订单对象已经存在 add_line_items_from_cart() 方法,现在我们该将其实现了。

Joe 提问:是否会创建重复订单?

Joe 非常关心我们在 newcreate action 中都使用了 Order 对象,但为什么在数据库中不会生成重复订单的原因。

答案非常简单,new action 只是在内存中创建了 Order 对象并将其提供给模板进行操作。当向浏览器发送完响应后,此订单对象已经被释放,并且最终被 Ruby 的垃圾收集器处理。它从不曾进行过数据库操作。

create action 也创建了一个 Order 对象,而它是由来自表单的数据构建。此对象确实存储于数据库中。所以,虽然 model 对象扮演了匹配进入和输出数据库数据的两个角色,但它也可以像常规对象一样处理业务数据。只有你明确让它们进行数据库操作时(比如调用 save() 方法)它们才会对数据库造成影响。

# app/models/order.rb

class Order < ActiveRecord::Base
  #...
  # Here's new line
  def add_line_items_from_cart(cart)
      cart.line_items.each do |item|
          item.cart = nil
          line_items << item
      end
  end
end

要将购物车中商品条目转换至订单中我们需要进行两步。首先,我们在订单中将商品条目的 cart_id 赋值为 nil,避免在删除购物车时导致商品条目受到影响。

然后我们将商品条目添加至订单的 line_items 容器中。而且我们也不需要处理关于外键的工作,比如将商品条目的 order_id 赋值为当前新创建的订单 ID。Rails 通过我们添加的 has_many()belongs_to() 声明会关联操作 OrderLineItem model。关于每个被添加至 line_items 中的商品条目的键值管理都是由 Rails 处理。我们只需要修改测试反应当前新的重定向地址即可。

# test/controllers/orders_controller_test.rb

test "should create order" do
  assert_difference('Order.count') do
    post :create, order: { address: @order.address, email: @order.email, name: @order.name, pay_type: @order.pay_type }
  end

  # Here's new line
  assert_redirected_to store_path
end

所以作为第一个测试,我们要在复核页面表单还没有填入任何参数时点击「Place Order」按钮。你会看见如下图的错误信息展示。

Full house!Every field fails validation.png

如果我们填写了相应数据(如下图所示的数据)再点击「Place Order」按钮,我们将返回分类页面,就像如下的第二张图一样。不过下单是正常的吗?我们先看看相应的数据库。

Entering order information produces a "Thanks!".png

sqlite3 -line db/development.sqlite3

select * from orders;

select * from line_items;

.quit

尽管版本号和日期可能会有不同(如果你完成了 132 页的练习这里还会显示 price),但你会查看到一个订单以及一个或多个你选择的商品条目。

最后一个 Ajax 修改

当获取完订单后我们会重定向至首页,并展示存储成功的信息「Thank you for your order」。如果继续进行购物,由于我们使用了 JavaScript 所以界面也不需要重画,也就意味着通知信息会一直显示着。当我们再次添加商品至购物车时最好将它去除(就像之前在浏览器中使用 JavaScript 隐藏其他内容一样)。所幸处理起来也很容易,只要当我们添加商品至购物车时我们将包含提示信息的 <div> 标签隐藏即可。

// app/views/line_items/create.js.erb

// Here's new line
$('#notice').hide();

if ($('#cart tr').length >= 1) { $('#cart').show('blind', 100); }

$('#cart').html("<%= escape_javascript render(@cart) %>");

$('#current_item').css({'background-color':'#88ff88'}).
  animate({'background-color':'#114411'}, 1000);

当我们刚进入商铺界面时并没有提示信息,所以 ID 为 notice 的代码片段也不会出现。所以此时并不会存在 ID 为 notice 的标签,这里的 jQuery 代码也不会匹配任何元素。当对任意匹配到的元素进行 hide() 操作时什么都不会发生。这就是我们期望的,一切都还不错。

现在我们已经获取了订单,是时候通知订单处理部门了。关于这个功能我们需要使用 feed,关于订单我们还要专门使用 Atom-formatted feed。

迭代 G2:Atom Feeds

使用标准的 feed 格式(比如 Atom)将意味着你会立即获得面对多客户端的好处。因为 Rails 已经了解了 ID、日期和链接,它可以让你免受这些琐碎细节的干扰只专注于生成可读的简要。我们要先在 products controller 中添加一个新的 action。

# app/controllers/products_controller.rb

def who_bought
  @product = Product.find(params[:id])
  @last_order = @product.orders.order(:update_at).last
  if stale?(@last_order)
    respond_to do |format|
      format.atom
    end
  end
end

除了要获取商品之处我们还要检查请求是否已经过时。是否还记得我们在 104 页因为我们预计分类页面是高流量界面所以进行了部分结果缓存的处理?feed 也是类似的,不过只是使用的模式不同。与大量不同客户端都请求相同页面相比,我们只有少量客户重复请求相同界面。如果你对浏览器缓存比较熟悉,其实 feed 聚合器也是相似的。

Joe 提问:为什么是 Atom?

其实还有些其他的 feed 格式,比较常用的是 RSS 1.0、RSS 2.0 和 Atom,它们分别是 2000、2002 和 2005 标准。这三种格式都受到广泛的支持。为了方便紧急转换,许多站点都同时提供多种 feed 的支持,不过这并不是一种必备功能,反而会让用户晕头转向,一般来说并不建议如此做法。

Ruby 也提供了产生这些 feed 格式的基础库,其中也支持一些不太常见的 RSS 版本。最好选用三种主要版本之一使用。

Rails 框架会选取合理的默认值,而 Atom 就是 Rails 默认的 feed 格式。它被指定为以 IETF 进行网络交流时的标准网络协议,Rails 还提供了一个更高级别的辅助方法 atom_feed,它会处理基于 Rails 命名约定的许多详细信息,比如 ID 和日期。

通过这种方式发出的响应包含一些用于区分的元数据,包括最后一次修改的内容及调用 ETag 产生的哈希值。如果随后的请求将元数据返回,服务器就可以提供响应体为空和数据未发生变化的指标的响应。

通常在 Rails 中你无须考虑这些技术细节。你只需要区分内容的源即可,剩下的部分由 Rails 进行处理。在此示例中我们使用最后一个订单作为区分内容的源。在 if 声明中,我们一般会处理请求。

通过添加 format.atom Rails 会查找名字为 who_bought.atom.builder 的模板。这个模板可以使用 Builder 提供的 XML 生成函数,这些函数用于处理 atom_feed 辅助方法提供的 Atom feed 格式信息。

# app/views/products/who_bought.atom.builder

atom_feed do |feed|
    feed.title "Who bought #{@product.title}"

    feed.updated @last_order.try(:update_at)

    @product.orders.each do |order|
        feed.entry(order) do |entry|
            entry.title "Order #{order.id}"
            entry.summary type: 'xhtml' do |xhtml|
                xhtml.p "Shipped to #{order.address}"
                xhtml.table do
                    xhtml.tr do
                        xhtml.th 'Product'
                        xhtml.th 'Quantity'
                        xhtml.th 'Total Price'
                    end
                    order.line_items.each do |item|
                        xhtml.tr do
                            xhtml.td item.product.title
                            xhtml.td item.quantity
                            xhtml.td item.total_price
                        end
                    end
                    xhtml.tr do
                        xhtml.th 'total', colspan: 2
                        xhtml.th number_to_currency order.line_items.map(&:total_price).sum
                    end
                end
                xhtml.p "Paid by #{order.pay_type}"
            end
            entry.author do |author|
                author.name order.name
                author.email order.email
            end
        end
    end
end

我们会在 393 页讨论 Builder 中的更多细节。

在整个 feed 层级中我们需要提供两块信息,分别是标题和最后更新日期。在没有订单时 update_at 将是空值,Rails 会用当前时间进行取代。

接着我们遍历商品相关的每个订单。不过现在在两个 model 之间并没有直接建立联系,不过它们之前有一些间接联系。product 关联了许多 line_items,而 line_items 又属于订单。我们可以通过简洁地声明商品和订单之间通过 line_items 形成的间接关系将代码简化。

# TODO

class Product < ActiveRecord::Base
  has_many :line_items
  # Here's new line
  has_many :orders, through: :line_items

  #...
end

我们为每个订单都提供了标题,汇总和归属人。汇总完全由 XHTML 构成,我们通过这种方式生成一个关于商品标题、订购数量和总价的表格。接着表格的下方有一块包含了 pay_type

这个功能要正常运转还需要定义路由。action 会响应 HTTP GET 请求并处理一些容器元素(换句话说,在每个 product 中)而不是整个容器本身(在这个示例中就是所有 product)。

# app/config/routes.rb

Rails.application.routes.draw do
  resources :orders
  resources :line_items
  resources :carts
  get 'store/index'

  # Here's new block
  resources :products do
    get :who_bought, on: :member
  end
  # The priority is based upon order of creation: first created -> highest priority.
  # See how all your routes lay out with "rake routes".

  # You can have the root of your site routed with "root"
  root 'store#index', as: 'store'

  #...
end

我们再试一下。

curl --silent http://localhost:3000/products/3/who_brought.atom

看起来还不错。现在我们可以在喜欢的 feed 阅读器中订阅这个请求了。

最好的是客户很喜欢这个产品。我们实现了商品的维护、一个基础的分类界面和购物车,现在我们还拥有了一个简单的订单系统。显然我们还需要给这个应用添加一些功能,不过完全可以等到新迭代再进行。(由于这个迭代任务没有太多关于 Rails 的新知识可以讲,这本书将会跳过它)。

总结

在较短的时候里我们完成了下列任务:

  • 我们创建一个表单用于获取订单的详细信息并将它与一个新建的 order model 关联。

  • 我们添加了数据校验,并且使用辅助方法向用户展示错误。

  • 我们提供了一个 feed 以方便管理员处理他们的订单。

自习天地

下面有些知识需要你自己学习:

  • 获取 who_bought 请求处理的 HTML、XML 和 JSON 格式的 view。尝试通过 @product.to_xml(include::orders) 在 XML view 中展示订单信息。在 JSON view 也尝试一下相似的做法。
  • 如果在复核页面已经显示时点击「Checkout」按钮会发生什么?你可以在这种情况时将按钮禁用吗?
  • 可用的支付方式现在作为 Order 类中的常量进行保存。你可以将它转移到数据库中吗?如果移至数据库中后数据验证要如何处理?