08-Task-D-Cart-Creation

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

现在我们已经可以展示所有准备好的产品分类了,是时候将展示的商品进行售卖了。客户也同意我们的意见,所以我们决定接下来就实现一个基础的购物车。它将包含一些新观念,包括 session,model 间的关联,以及向 view 添加按钮,所以让我们开始吧。

迭代 D1:查找购物车

当用户浏览我们的在线商品时,他们将会选择商品进行购买。一般的习惯是将用户选择的商品添加至虚拟购物车中,购物车也只保留在我们商店中。有时,消费者已经选择完需要的商品,接着网站需要核查商品,并让他们为购物车中的商品付款。

这意味着我们的应用需要让消费者能够将所有的物品都添加至购物车中。要实现这个功能,我们需要保持会话的数据库和商店中购物车都是唯一的。每当有请求发送过来时,我们可以通过 session 获取购物车的唯一编码,通过编码我们可以在数据库中找到相应的购物车。

让我们创建一个购物车吧。

rails generate scaffold Cart

rake db:migrate

module CurrentCart extend ActiveSupport::Concern
  private

    def set_start
      @cart = Cart.find(session[:cart_id])
    rescue ActiveRecord::RecordNotFound
      @cart = Cart.create
      session[:cart_id] = @cart.id
    end
end

set_cart() 方法获取 session 中的 :cart_id 数据,而且目的是查找与此 ID 一致的购物车数据。如果购物车记录无法找到(当 ID 是 nil 或者无效时),这个方法将会创建一个新的 Cart,并将新建的购物车对象 ID 存储于 session 中,最后将新建的购物车对象返回。

要注意,我们是将 set_cart() 方法放置于 CurrentCart module 中,并且是将它设置为 private。这种方式允许我们在 controller 间共享公共代码(即使只是单独的一个方法),此外通过 Rails 的辅助还可以让它成为 controller 中的 action 方法。

迭代 D2:将商品与购物车关联

我们关注 session 是因为我们需要一个地方存储购物车。我们在 331 页更加深入地了解 session,现在的话我们继续实现购物车即可。

让我们保持事物的简洁性。购物车包含一组商品。基于之前的数据草图,再加上我们与客户的简短谈话,我们现在可以生成 Rails model,并移植它创建相应的表。

rails generate scaffold LineItem product:references cart:belongs_to

rails db:migrate

数据库已经可以存储购买物品与购物车之间的关系。如果你看一看生成的 LineItem 类定义,你可以看见它已经表述了这些关系。

class LineItem < ActiveRecord::Base
  belongs_to :product
  belongs_to :cart
end

在 model 级别中,「references」与「belongs to」并没有差别,都是通过 belongs_to() 方法实现。在 LineItem model 中,两次 belongs_to() 是告诉 Rails,line_items 表中的数据是 carts 表和 products 表数据的子数据。没有商品条目可以不依托购物车和商品存在。有一种牢记 belongs_to 声明的简单方法,如果一个表有外键,每个外键对应的 model 就是一个 belongs_to

这种声明的目的是什么?一般来说,它们可以给 model 对象增加导航性。由于 Rails 给 LineItem 添加了 belongs_to 声明,我们现在可以通过它反向找到代表的商品并展示书名。

li = LineItem.find(...)
puts "This line item is for #{li.product.tilte}"

为了能够直接使用它们的关联关系,我们还需要在 model 文件中添加一些声明用以表示它们之间的反向关系。

打开 app/models/cart.rb 文件,并且添加 has_many 方法调用。

class Cart < ActiveRecord::Base
  has_many :line_items, dependent: :destroy
end

has_many :line_items 部分已经足以自解释,一个购物车会有许多关联的商品条目。因为每个商品条目都包含了自身所关联的购物车 ID,所以它们都可以关联到购物车。dependent: :destroy 部分表示商品条目的存在是依赖于购物车的存在的。如果我们销毁了购物车,也就是说从数据库将它删除,我们希望 Rails 也将相关联的商品条目删除。

现在 Cart 已经声明其关联多个商品条目,我们也可以通过购物车对象获取相应的商品条目。

cart = Cart.find(...)
puts "This cart has #{cart.line_items.count} line items"

现在,为了完整性,我们也应该在 Product model 中添加 has_many。毕竟,如果我们有许多购物车,每个商品也应该有许多商品条目关联它。并且,我们还需要通过检测代码在商品移除时保护一下相应的商品条目。

class Product < ActiveRecord::Base
  has_many :line_items

  before_destroy :ensure_not_referenced_by_any_line_items

  private

    def ensure_not_referenced_by_any_line_items
      if line_items.empty?
        return true
      else
        errors.add(:base, 'Line Items present')
        return false
      end
    end

  #...
end

我们已经声明了一个商品与多个商品条目关联,并且也定义了一个「hook(钩子)」方法,叫做 ensure_not_referenced_by_any_line_item()。钩子方法是一种在对象生命周期中,通过提供锚点由 Rails 自动调用的方法。在这个示例中,在 Rails 尝试从数据库中删除一行数据时此方法将被调用。如果钩子方法返回 false,此行数据就不会被删除。

要注意我们是直接访问 errors 对象的。这与 validates() 存储错误信息时是类似的。错误可以通过单独参数关联,不过在这个例子中,我们是通过基础对象关联的。

我们在 282 页会讨论更多关于内部 model 间的关联关系。

迭代 D3:添加按钮

现在准备工作就绪,是时候给每个商品添加「Add to Cart」按钮了。

我们甚至不需要创建新 controller 和新 action。只要找找由脚手架生成器提供的 action 即可,你可以找到 index()show()new()edit()create()update()destroy()。相对匹配的操作是 create()。(new() 听起来也还不错,不过它是用来接收表单数据的,而 create() 就是用来接收请求的。)

既然已经做好了决定,接下来的就是按部就班。我们要创建什么呢?确切地说,即不是 Cart 也不是 Product。我们是要创建 LineItem。看看 app/controllers/line_items_controller.rb 中 create() 方法的相关注释,你可以看到它也声明了 URL (/line_items)和 HTPP 请求方式(POST)。

这个选项建议由 UI 控制使用。在我们使用 link_to() 添加链接之前,链接默认是使用 HTTP GET 请求方式。我们现在想用 POST 方式,所以我们这次要通过添加按钮的方式实现,这也意味着我们将要使用 button_to() 方法。

我们可以将按钮与商品条目指定的 URL 进行关联,不过我们依然可以让 Rails 来处理这些细节,我们可以直接使用 controller 名字加 _path。在这个例子中,我们要使用 line_items_path

不过,此时还有个问题,line_items_path 怎么知道要添加哪个商品至购物车呢?我们需要通过按钮传递相应商品的 ID。这也非常简单,我们需要做的就是在 line_item_path() 调用时添加 :product_id 参数。我们甚至可以传递 product 实例,Rails 会自行根据情况提取记录的 ID,比如现在情况就满足。

总而言之,我们就是要编写如下的 index.html.erb 代码:

<% if notice %>
  <p id="notice"><%= notice %></p>
<% end %>

<h1>
  Your Pragmatic Catalog
  <span>accessed <%= pluralize(@access_times, 'time') %></span>
</h1>

<% cache ['store', Product.latest] do %>
  <% @products.each do |product| %>
    <% cache ['entry', product] do %>
      <div class="entry">
        <%= image_tag(product.image_url) %>
        <h3><%= product.title %></h3>
        <%= sanitize(product.description) %>
        <div class="price_line">
          <span class="price"><%= number_to_currency(product.price, unit: "¥") %></span>
          # This is new line
          <%= button_to 'Add to Cart', line_items_path(product_id: product) %>
        </div>
      </div>
    <% end %>
  <% end %>
<% end %>

这还有一个格式化问题,button_to 会创建一个 HTML <form>,这个表单会包含 <div> 标签。由于它们都是块级元素,它们会换行显示。我们想将它们放置于价格下方,所以我们要添加一些 CSS 魔法,使它们能够处于同一行。

p, div.price_line {
  margin-left: 100px;
  margin-top: 0.5em; 
  margin-bottom: 0.8em; 

  form, div {
    display: inline;
  }
}

现在我们的首页展示如下图。不过在我们点击按钮前,我们需要修改 商品条目中的 create() 方法,让它期望表单参数中有一个商品 ID。通过这里你也能看出 id 字段在我们的 model 中是多么重要了。Rails 区分 model 对象(以及相应的数据库记录)都是通过它们的 id 字段。如果我们向 create() 方法传递了一个 ID,我们就可以唯一地区分添加的商品。

add-to-cart-button.png

为什么是 create() 方法?链接默认的 HTTP 请求方式是 get,而按钮的默认 HTTP 请求方式是 post,Rails 通过这些约定决定调用哪个方法。通过 app/controllers/line_items_controller.rb 中的注释也可以了解其他的约定。在 Depot 应用中我们将会对这些约定进行广泛的使用。

现在我们要修改 LineItemsController 根据当前的会话查找购物车(如果没有相应的购物车就创建一个),并且向购物车添加选择的商品,并且展示购物车中的内容。

我们要使用在迭代 D1 时实现的 CurrentCart ,它可以通过会话查找一个购物车。

class LineItemsController < ApplicationController
  include CurrentCart, AccessCounter
  before_action :set_start, only: [:create]
  before_action :set_line_item, only: [:show, :edit, :update, :destroy]

  #...
end

我们 include 了 CurrentCart 模块,并且也声明了 set_cart 方法在 create() 前调用。我们会在 337 页深入讲解 action 回调,现在我们只需要知道 Rails 提供了在 action 被调用前将它与其它方法绑定顺序执行的功能。

实际上,就像你看到的一样,controller 生成时已经通过相应的功能在 show()edit()update()destroy() 调用前设置 @line_item 实例变量值。

现在我们知道 @cart 被赋的值是当前购物车,我们需要修改的是 app/controllers/line_items_controller.rb 中的几行代码,并通过这几行代码构建商品条目。

# app/controller/line_items_controller.rb
def create
  product = Product.find(params[:product_id])
  @line_item = @cart.line_items.build(product: product)

  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

我们通过 params 对象获取请求中的 :product_id 参数。params 对象在 Rails 中十分重要。它携带所有通过浏览器请求传递的参数。由于需要在 view 中使用,所以我们将结果存储为局部变量中。

接着,我们向 @cart.line_imtes.build 方法传递我们查找到的商品。这是由于一个新商品条目关系在 @cart 对象和 product 之间需要建立。你可以从尾端建立关联,不过 Rails 关心的是从任一一端都可以建立关联关系。

我们商品条目结果存储为 @line_item 变量。

方法剩下的部分都是在处理错误和处理 JSON 请求,我们会在 10.2 节详细讨论。不过现在,我们还要再处理一个事情,当商品条目被创建后,我们想向重定向返回的是购物车而不是商品条目。当商品条目对象已经知道如何获取购物车对象后,我们需要做的就是将 .cart 添加至方法调用中。

由于我们修改了 controller 方法,所以我们知道,我们还需要修改相应的功能测试。我们要在调用 create 方法时传递商品 ID,并且修改我们所期望重定向的目标地址。我们要通过更新 test/controllers/line_items_controller_test.rb 实现。

test "should create line_item" do
  assert_difference('LineItem.count') do
    post :create, product_id: products(:ruby).id
  end

  assert_redirected_to cart_path(assigns(:line_item).cart)
end

我们还没有讲述过 assigns 方法,因为这个方法是通过脚手架命令自动生成的。这个方法可以帮助我们访问已经被(或者可以被) controller 分配给 view 使用的实例变量。

我们现在运行下这组测试。

rake test test/controllers/line_items_controller_test.rb

代码运行结果如同期望的一样使我们信心大增,我们可以在浏览器中测试操作一下「Add to Cart」按钮。

我们所看到的应该如下图展示的一样。

confirmation-request-was-processed.png

还是有点让人失望。尽管我们已经构建了购物车的脚手架,但我们并没有提供任何属性,所以 view 也不会有任何展示。现在,让我们写个模板(过后我们再美化它)。

<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>

<h2>Your Pragmatic</h2>
<ul>
  <% @cart.line_items.each do |line_item| %>
    <li><%= line_item.product.title %></li>
  <% end %>
</ul>

流程需要从头至尾看一下,所以我们回到 「Add to Cart」按钮处再点击一次,并且看看基本的 view 展示,就像下图一样。

cart-with-new-item-displayed.png

再次回到 http://localhost:3000 主页上,向购物车添加另一件商品。你会在购物车中看见原来的两件商品及新商品。看起来我们的会话是正常运转的。是时候向客户展示我们的成果了,我们要自豪地向她展示智慧的新购物车。不过结果让我们沮丧,她发出不太满意的声音。

她解释说,真正的购物车不会将相同的商品分开展示。它们是通过显示数量 2 进行处理的。看起来我们需要在下个迭代中处理这些问题。

总结

这个迭代事情比较多,忙碌的日子即将到来。我们已经在商店中添加了购物车,我们也开始涉足一些 Rails 内置功能。

  • 在一次请求中我们创建一个 Cart 对象,而且通过同一个会话对象发起的请求都将是使用同一个购物车。

  • 我们添加了一个私有方法,并让它在所有的 controller 中都可以被访问。

  • 我们创建了购物车与商品条目之间,商品条目与商品之间的关系,我们可以通过这些关系互相导航。

  • 我们添加了一个按钮用于将商品添加至购物车,并且创建一个新商品条目。

自习天地

这有些知识需要自己尝试:

  • 给 session 添加新变量用于记录用户访问 store controller 的 index action 次数。要注意第一次页面被访问时相关统计还没有存储在 session 中。你可以通过 if session[:counter].nil? 判断。如果 session 变量不存在,你就需要初始它,然后才能对它进行递增。
  • 将统计值传递给模板,并且在分类界面显示它。提示:pluralize 辅助方法可能对于格式化显示信息是有用的
  • 当用户向购物车添加商品时将统计值重值为零
  • 如果统计值大于 5 后改变展示统计值的模板