使用 ActiveModel 將資料轉換成模型物件後,我們可以繼續利用 ActiveRecord 來跟資料庫整合,達到持久化資料的效果。接下來我們會修改現有的實作,讓資料可以持久化的被保存起來。
ActiveRecord 是基於 ActiveModel 所以製作的,因此我們只需要少量的重構就可以實現持久化保存的效果。
建立資料庫
要使用 ActiveRecord 持久化保存資料,我們需要先建立資料庫已經定義資料結構。在這之前,我們需要先將 ActiveRecord 加入到專案中。
修改 Gemfile
加入所需的套件。
1# ...
2
3gem 'activemodel', require: 'active_model'
4gem 'activerecord', require: 'active_record'
5
6# ...
7group :test do
8 # ...
9 gem 'database_rewinder'
10end
因為 DatabaseRewinder 會需要 ActiveRecord
的存在,因此我們在 Gemfile
中提示要載入 active_record
而不是在 app.rb
中手動撰寫 require 'active_record'
來引用,我們後續會在測試階段使用 DatabaseRewinder 來清楚資料確保測試環境乾淨。
接下來加入 database.rb
進行資料庫相關的定義。
1# frozen_string_literal: true
2
3DATABASE_CONFIG = Bundler.root.join('config/database.yml')
4ActiveRecord::Base.configurations = YAML.safe_load(DATABASE_CONFIG.read, aliases: true)
5ActiveRecord::Base.establish_connection(ENV['RACK_ENV'].to_sym)
6
7ActiveRecord::Schema.verbose = false
8ActiveRecord::Schema.define do
9 create_table :products, if_not_exists: true do |t|
10 t.string :name, null: false
11 t.integer :price, default: 0
12 end
13
14 create_table :carts, if_not_exists: true
15 create_table :cart_items, if_not_exists: true do |t|
16 t.bigint :cart_id, null: false
17 t.bigint :product_id, null: false
18 t.integer :amount, default: 0
19
20 t.index %i[cart_id product_id], unique: true
21 end
22end
我們先將 config/database.yml
的內容載入進來作為資料庫的設定,並且根據 RACK_ENV
來選擇要使用的資料庫環境。
接下來我們利用 ActiveRecord 的 ActiveRecord::Schema
機制直接定義資料表來用比較簡易的方法定義開發環境,為了避免開發環境因為重複啟動而衝突,在 create_table
的定義中加入了 if_not_exists: true
的檢查。
在正式的專案中,還是要使用 Migration 來維護 Schema 的變更會更好。
最後我們補上 config/database.yml
的內容,讓測試環境直接使用 SQLite 的記憶體模式進行測試。
1default: &default
2 adapter: sqlite3
3 database: ':memory:'
4
5development:
6 <<: *default
7 database: db/development.sqlite3
8
9test:
10 <<: *default
重構模型
既然現在可以串連資料庫,那麼原本在 Model 中直接保存在記憶體的實作就可以移除,並且改為繼承 ActiveRecord::Base
來使用資料庫保存這些資料。
首先,開啟 models/product.rb
清除掉原本的實作,直接繼承自 ActiveRecord::Base
1# :nodoc:
2class Product < ActiveRecord::Base
3end
接下來開啟 models/cart.rb
調整實作配合資料庫的版本。
1# :nodoc:
2class Cart < ActiveRecord::Base
3 has_many :items, class_name: 'CartItem', autosave: true
4
5 def update_amount(product_id, count)
6 item = items.find { |i| i.product_id == product_id } || items.build(product_id:)
7 item.adjust(count)
8 end
9end
因為我們這次加入了 CartItem
來記錄品項的數量,不能像之前使用 Hash 的做法直接修改,需要改為從 #items
裡面找出商品 ID 相同的品項,並且使用 item.adjust(count)
來調整數量。
items.build(product_id:)
是 Ruby 3 的縮寫機制,這裡我們不會做item.save
是為了避免 Side Effect(副作用)和 N + 1 Query 的原因,實際保存的處理應該要在 Controller 層級處理。
最後,增加 models/cart_item.rb
來實現購物車品項的數量調整機制。
1# :nodoc:
2class CartItem < ActiveRecord::Base
3 def adjust(count)
4 self.amount = [amount + count, 0].max
5 end
6end
隨著重構跟拆分物件,每個物件負責的功能越來越明確並且單一方法的行數也漸漸的減少變得更容易維護。
調整寫入行為
最後我們要對 app.rb
做調整,將原本的寫入行為調整成配合資料庫處理的版本,並且運行 Cucumber 來驗證修改前後的行為是一致的。
開啟 app.rb
對 POST /api/cart
的實作進行調整。
1# ...
2require_relative 'database'
3Dir['models/*.rb'].sort.each { |path| require_relative path }
4
5# ...
6module Shop
7 class API < Grape::API
8 # ...
9 namespace :api do
10 # ...
11 post '/cart' do
12 @cart = Cart.find_or_initialize_by(id: 1)
13 @cart.update_amount(params[:id], params[:amount])
14 @cart.save
15
16 @cart.items.map do |item|
17 [item.product_id, item.amount]
18 end.to_h
19 end
20 end
21 end
22end
因為我們在原本的設計,購物車會回傳 { "1" => 10 }
這樣的資料結構,因此需要將變成 Array 的 @cart.items
重新處理為 Hash 的型態進行回傳。
這段在 MVC 框架中大多是 View 的任務,我們也可以使用像是 Serializer 的套件來定義最終要輸出的資料型態。
在運行測試之前,我們需要將 features/support/env.rb
原本呼叫 Product.rest
的段落,改為使用 DatabaseRewinder 來對資料庫做清理的處理。
1# ...
2
3BeforeAll do
4 DatabaseRewinder.clean_all
5end
6
7Before do
8 DatabaseRewinder.clean
9end
最後就可以運行 bundle exec cucumber
來確認修改後的成果符合我們的預期。
如果想要搭配前端測試,也可以利用 SQLite 命令,手動插入資料來進行確認。
1$ sqlite3 db/development.sqlite3
2> INSERT INTO products(name, price) VALUES ('Ruby 秘笈', 100), ('RSpec 秘笈', 150);
3> .exit
4$ bundle exec rackup
5# 另一個 Shell
6$ curl localhost:9292/api/products
7[{"id":1,"name":"Ruby 秘笈","price":100},{"id":2,"name":"RSpec 秘笈","price":150}]
開啟前端的專案,就能看到商品列表現在可以依照我們對資料庫內容的調整做出不同的反應。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 同時完成測試與文件 - Cucumber 的文件測試法
- 基本語法:功能描述 - Cucumber 的文件測試法
- 基本語法:驗證行為 - Cucumber 的文件測試法
- 基本語法:步驟定義 - Cucumber 的文件測試法
- 基本語法:輔助設定 - Cucumber 的文件測試法
- 前端環境:Vite 與 Cucumber - Cucumber 的文件測試法
- 商品列表與加入購物車 - Cucumber 的文件測試法
- 重構與移出購物車 - Cucumber 的文件測試法
- 商品資料與總價 - Cucumber 的文件測試法
- 結帳與結果 - Cucumber 的文件測試法
- 整理前端實作 - Cucumber 的文件測試法
- 初始化後端專案 - Cucumber 的文件測試法
- 商品資料 API - Cucumber 的文件測試法
- 更新購物車 API - Cucumber 的文件測試法
- 加入資料模型 - Cucumber 的文件測試法
- 持久化保存 - Cucumber 的文件測試法
- 結帳處理 - Cucumber 的文件測試法
- 在 Rails 的前後端分離 - Cucumber 的文件測試法
- 匯入前端實作 - Cucumber 的文件測試法
- 重現後端實作 - Cucumber 的文件測試法
- 累積價值 - Cucumber 的文件測試法