Rails 的 ORM 提供很便利的語法讓工程師可以輕鬆地對資料庫作查詢,但是在某些場景裡仍讓工程師們感到就有些美中不足,例如: 聯集(UNION)。
假定系統需要一個 my tracking list 的頁面,這個頁面必需列出 manager 和 tracking product,並且針對它們去做更進一步的查詢操作,如列出 publish 的 product,你會怎麼設計?
似乎這一切聽起來令人頭疼,但看完後面的範例,你也能暸解實現聯集的作法。
# name :string(255)
# description :text
# status :string(255)
# manager_id :integer
class Product < ActiveRecord::Base
belongs_to :manager, :foreign_key => :manager_id, :class_name => "User"
has_many :tracking_lists
has_many :tracking_users, :through => :tracking_lists, :source => :user
end
class User < ActiveRecord::Base
has_many :products
has_many :tracking_lists
has_many :tracking_products, :through => :tracking_lists, :source => :product
end
# id :integer
# user_id :integer
# product_id :integer
class TrackList < ActiveRecord::Base
belongs_to :user
belongs_to :product
end
起初我們會想到 Array 的簡單作法
@products = Set.new
manage_products = @user.products.is_publish
tracking_products = @user.tracking_products.is_publish
@products.merge(manage_products)
@products.merge(tracking_products)
維護性: 低
- (優) 寫起來快,幾乎不用思考就寫完了...
- (劣) 需要下兩道 query,且無法合併一起查詢
- (劣)
Array
的instance
無法做更進一步的查詢操作,例:order(:created_at)
...etc 。 - (劣) 速度比較慢
但我們始終覺得這樣的做法效率太差,且維護性非常的低...
然後我們想到 find_by_sql + left join 的作法
@products = Product.find_by_sql(["
SELECT p0.* FROM product as p0
LEFT JOIN track_lists as t1 on t1.product_id = p0.id AND t1.user_id = p0.manager_id
where p0.manager_id = ? AND p0.status = 'published';
", user.id])
維護性: 中
- (優) 只有一道 query
- (劣) 需以 product 的 query 條件為主,當 user 沒有被 assigned manage 某樣商品時,結果就會為空
- (劣) 由於 find_by_sql 出來的結果是
Array
,無法做更進一步的查詢操作,例:order(:created_at)
...etc 。
然而這種解法還是不夠方便,且當有不同的 query 需求,例如: is_draft?,我們就必須再重寫一次 method。
最後我們發現可以這樣做 from + union
manage_products_sql = @user.products.to_sql
tracking_products_sql = @user.tracking_products.to_sql
@products = Product.from("( #{ manage_products_sql } UNION #{ tracking_products_sql } ) AS products").is_publish
維護性: 高
- (優) 只有一道 query
- (優) from 出來的結果是 ActiveRecord::Relation,我們可以續用
QueryMethods
做更進一步的查詢操作 - (優) 簡單好維護
備註:
- to_sql 只會產生 query 的
字串
,並不會實際去下 query - UNION 是 mysql 的聯集語法,且預設的 distinct 只會取一次相同的資料
RSpec 是 Rails 的其中一種測試框架,深受國外社群的愛戴,它的撰寫方式簡單易懂,非常好上手;語意化的設計讓測試代碼變得直觀,即便是初次學習的工程師也能夠迅速理解。
然而,台灣關於 RSpec 的文獻實在不多(找過比較完整的介紹是 ihower 的實戰聖經),加上 RSpec 還在發展中,不同派別的語法和 convention 很容易混在一起,要釐清它們並整理出一個最簡單可行的版本實在有些因擾。 但其實都沒想像中的困難,看完下面的敘述,你也能立刻寫出一個可以運行的測試代碼。
設定一個 RSpec 測試環境
由於 RSpec 不是 Rails 預設的 test framework ,在進行測試代碼開發前,我們必需先準備好測試環境。
安裝 rspec-rails 套件
在 Gemfile 裡將 rspec-rails 設定在 development 和 test 的 group 底下,並執行 bundle install
的指令,它會自動安裝好所需的套件。
group :development, :test do
gem 'rspec-rails', '~> 3.0.0.beta'
end
建立 rspec-rails 基本設定
執行 rails generate rspec:install
的指令,它會自動新增 RSpec 所需的檔案和設定。
#=> create .rspec
#=> create spec
#=> create spec/spec_helper.rb
建立一個 test 專用的 database
在 database.yml 中設定 test db 的參數
development: &default
adapter: mysql2
encoding: utf8
database: blog_development
host: localhost
username: root
password: ""
test: &TEST
<<: *default
database: blog_test
接著執行 rake db:test:prepare
,rake 會根據 schema 新增一個測試用的 database。
學會 RSpec 的基礎語法
RSpec 提供很多 sugar syntax 和 features 讓我們寫出漂亮的測試代碼,這些語法經常會對初學者的腦袋造成混亂。 其實只要幾個基本的語法,就可以寫出簡單的測試。 下面將以 board 為例,說明如何運用它們
# title :string(255)
# description :text
class Board < ActiveRecord::Base
end
執行 rails g rspec:model board
指令,它會在 spec 的 model 資料夾底下新增一個 board 的 spec 檔案。
require 'spec_helper'
describe Board do
pending "add some examples to (or delete) #{__FILE__}"
end
context 與 describe
context 和 describe 都是用來宣告 example group 的語法,一般會將 describe 當作測試 feature 的 group。
describe "#is_news?" #=> 井號代表 instance method 的測試
describe ".find_by_title" #=> 句號代表 class method 的測試
而 context 通常當作 condition 的 group。
context "when title is empty"
context "when title is News"
it
每個 it 都代表一個 test example,而測試代碼都必需在 it 的 block 裡宣告才會被執行 ; 在尚未填入 block 之前,it 只會列在 pending 的清單裡。
describe Board do
describe "#is_news?" do
it "returns true when eq news"
end
end
OUTPUT
expect && eq
expect 和 eq 是 test example 裡很常被用到的語法,通常是 expect(預期的對象).to eq(預期的結果)。
由於 blog_test 裡面完全沒有資料,我們必需先產生 Board 的資料再來驗證。
it "returns true when eq news" do
@board = Board.create!(:title => "news")
expect(@board.is_news?).to eq(true) #=> 在這裡也可以用 be_true 來表示
end
OUTPUT
當 example 測試跑完,RSpec 會顯示 example 的數量和失敗的數量,failures 等於 0 的情況才算測試成功。
學會判斷 RSpec 的測試失敗 message
稍早我們已經知道測試成功的 message,另一方面也必需了解如何從錯誤的訊息中判別,以下圖為例
Red 代表某些 example 失敗了
message 會將失敗的代碼上色並且提供相關的訊息,只要見到紅色的文字出現,就代表有 example 失敗了。
列出失敗的行數與目的
message 會提供失敗的代碼行數和該代碼的測試目的,讓 RD 能夠迅速理解代碼的邏輯。 如圖中的 describe,我們很清楚的知道,當 @board 是 news 的時候 is_news? 應該要 return true。
expect 的 value
message 會印出 expect 的真實 value,例如 @board.is_news? 預期會 return true,但實際上獲得的 value 卻是 false。
國外的 Rails 社群認為寫測試已經像是呼吸一樣再自然不過的事情,他們認為測試給工程師帶來許多好處,例如:可以即時驗證程式碼的結果、可以釐清程式設計的思考邏輯、增加網站的維護性等。
然而,DHH 認為任何測試都是需要成本的 (Testing like the TSA)
Every line of code you write has a cost. It takes time to write it, it takes time to update it, and it takes time to read and understand it. Thus it follows that the benefit derived must be greater than the cost to make it.
站在專案公司的角度來看,每個專案在時間與金錢有限之下,在時程內 deliver 網站並且上線已經是 First Priority 的目標,測試的程度和涵蓋範圍似乎從來不在考量之內,肉測 (肉眼測試、人體手動測試) 也一樣可以達到驗證的效果。 運氣好的人可以順利過關,運氣差一點的則陷入時程 delay 的泥沼裡。 為確保專案的維護性能夠維持在某個程度之上,我們應該集中火力針對某些情況寫測試。
單一行為串聯太多 association
我們以購物車下訂單的例子來看
# cart model
class Cart < ActiveRecord::Base
has_many :cart_items
has_many :cart_budnels
end
# order model
class Order < ActiveRecord::Base
has_many :order_items
has_many :order_budnels
end
當購物車的品項需要轉換成訂單的時候,此時必需根據cart 的 cart_items 資訊來新增 order 底下的 order_items
這行為可能的結果有:
- 如果有 Cart Item,沒有Cart Bundle,就只新增 Order Item
- 如果有 Cart Bundle,沒有Cart Item,就只新增 Order Bundle
- 如果有 Cart Item、Cart Bundle,就兩個都新增
- 如果沒有 Cart Item、Cart Bundle,就都不要新增
我們必需手動產生符合 四種結果 的資料來測試。
但萬一今天客戶需要加價購的功能呢?
# cart model
class Cart < ActiveRecord::Base
has_many :cart_items
has_many :cart_budnels
has_many :cart_extra_purchases
end
# order model
class Order < ActiveRecord::Base
has_many :order_items
has_many :order_budnels
has_many :order_extra_purchases
end
我們必需手動產生符合 更多不同的結果 的資料來測試。
假設後續客戶還需要修改相關的行為,又必需再用同樣耗時的手法測試。
單一行為需要判斷多重條件
我們以驗證 coupon 有無效果的例子來看
class Coupon < ActiveRecord::Base
def self.valid?(code, amount)
response = {:valid => false}
coupon = find_by_code(code)
if coupon
if coupon.quantity > 0
if (coupon.start_date.beginning_of_day...coupon.end_date.end_of_day).cover?(DateTime.now)
if amount > coupon.price_minimum
response = {:valid => true, :msg => "Coupon code valid"}
else
response = {:msg => "This coupon code has minimum price of NTD #{coupon.price_minimum}"}
end
else
response = {:msg => "This coupon code has been expired."}
end
else
response = {:msg => "This coupon code is not available."}
end
else
response = {:msg => "This coupon code is not valid."}
end
response
end
end
當使用者結帳時填入 coupon ,系統必需驗證這個 coupon 的有效性。
這行為可能的結果有:
- 如果沒有 coupon
- 如果 coupon 數量不夠
- 如果 coupon 過期
- 如果 coupon 低於消費金額門檻
- 如果 coupon 有效
我們同樣必需耗費許多時間手動產生符合 五種結果 的資料來測試。
API 的串接
系統需要串接第三方服務的時候
class AlipayController < ApplicationController
def complete
if params[:alipay].present?
if Order.find([:alipay][:order_id])
process_order_paying
redirect_to checkout_complete_path, :notice => "支付寶 付款成功"
else
redirect_to checkout_failed_path, :notice => "支付失敗, 請與本站聯絡."
end
else
redirect_to root_path, :notice => result[:msg]
end
end
end
系統得根據外部回傳的 response 進行結帳後的動作。
我們得把時間花在不斷地執行結帳流程來獲得 response 以便修改程式碼。
註:DHH 的 Testing like the TSA 另外談論到 7 點關於 test 的想法,有興趣的可以去看一下。
Proc 與 yield 的差別在於,proc 會將 block 給暫存起來,等到需要用的時候,再以 block.call 的方式呼叫出來,yield 則是自動執行 block.call 。
新增一個 proc object 的做法有兩種,proc & lambda。
#Proc New
my_proc = Proc.new { puts "tweet" }
my_proc.call
#Lambda
my_proc = lambda { puts "tweet" } #1.9以上版本也可以寫成 my_proc = -> { puts "tweet" }
my_proc.call
#result
"twitter"
Proc 也可讓你執行兩個以上的 block
class Tweet
def post(success, error)
if current_user
success.call
else
error.call
end
end
end
tweet = Tweet.new
success = lambda { puts "Sent!"}
error = lambda { puts "No user login"}
tweet.post(success, error)
Proc 如果要轉換成 block
tweets = ["First tweet", "Second tweet"]
printer = lambda { |tweet| puts tweet }
tweets.each(printer) # 會噴錯誤,因為each吃的是block
tweets.each(&printer) # 加上 '&' 就可將proc轉換成block
而 proc 和 lambda 到底有哪些差異的地方?
Lambdas 會檢查傳入的參數,而Procs不會
def puts_parameters(code)
code.call(1, 2)
end
l = lambda { |a, b, c| puts "#{a} is a #{a.class}, #{b} is a #{b.class}, #{c} is a #{c.class}" }
p = Proc.new { |a, b, c| puts "#{a} is a #{a.class}, #{b} is a #{b.class}, #{c} is a #{c.class}" }
puts_parameters(l)
#result
"ArgumentError: wrong number of arguments (2 for 3)"
puts_parameters℗
#result
"1 is a Fixnum, 2 is a Fixnum, is a NilClass"
Lambdas 會直接skip return,而 Procs不會
def return_using_proc
Proc.new { return "Hi from proc!"}.call
puts "end of proc call"
end
def return_using_lambda
lambda {return "Hi from lambda!"}.call
puts "end of lambda call"
end
return_using_proc
#result
"Hi from proc!"
return_using_lambda
#result
"end of lambda call"
Yield
Yield的概念其實就是block.call,把block裡的內容帶到method中。
快速看以下例子就可以瞭解。
def call_this_block_twice
yield
yield
end
call_this_block_twice { puts "twitter" }
#result
"twitter"
"twitter"
當call_this_block_twice加上了{ puts "twitter" } 後,這個block就會被傳遞到method裡。
Yield也可以使用變數
def call_this_block
arg = "twitter"
yield arg
end
call_this_block { |arg| puts arg }
#result
"twitter"
call_this_block { |arg| arg.upcase }
#result
"TWITTER"
加上變數後,block在被呼叫的時候就可以使用這個變數。
Yield也可以直接回傳value
def puts_this_block
puts yield
end
puts_this_block { "twitter" }
#result
"twitter"
block裡即使只有純文字,也會被傳遞到yield。
利用yield讓code變得更簡潔
class Timeline
def list_tweets
@user.friends.each do |friend|
friend.tweets.each { |tweet| puts tweet }
end
end
def store_tweets
@user.friends.each do |friend|
friend.tweets.each { |tweet| tweet.cache }
end
end
end
list_tweets 和 store_tweets的code幾乎一模一樣,用yield可以做的更漂亮
class Timeline
def each
@user.friends.each do |friend|
friend.tweets.each { |tweet| yield tweet }
end
end
end
timeline = Timeline.new(user)
timeline.each { |tweet| puts tweet }
timeline.each { |tweet| tweet.cache }
include 和 extend 的用法
- include 讓module的method作用在實例變數上
- extend 讓module的method作用在類別變數上
看以下兩個例子
include
module Foo
def foo
puts 'heyyyyoooo!'
end
end
class Bar
include Foo
end
include module Foo 後,foo只能使用在Bar的實例變數上
Bar.new.foo # heyyyyoooo!
Bar.foo # NoMethodError: undefined method ‘foo’ for Bar:Class
extend
module Foo
def foo
puts 'heyyyyoooo!'
end
end
class Bar
extend Foo
end
extend module Foo 後,foo只能使用在Bar的類別變數上
Bar.new.foo # NoMethodError: undefined method ‘foo’ for #<Baz:0x1e708>
Bar.foo # heyyyyoooo!
Super主要是呼叫parent method的內容
- 不帶括號的 super 會呼叫parent method 的內容
- 帶括號的 super() 則是呼叫parent method 的內容,並且帶入括號內的參數
接下來看幾個例子
不帶括號的super
class Parent
def my_method
puts "Parent is awesome"
end
end
class Kid < Parent
def my_method
puts "Kid is awesome"
super
end
end
當呼叫 b 的method時,super會再次呼叫 a method的內容
kid = Kid.new
kid.my_method
#Output
"Kid is awesome"
"Parent is awesome"
帶括號的super()
class Parent
def my_method(family = "Father")
puts "#{family} is awesome"
end
end
class Kid < Parent
def my_method(string = "Mother")
puts "Kid is awesome"
super(string)
end
end
當呼叫 b 的method時,super會再次呼叫 a method的內容,並且帶入 b 的參數
kid = Kid.new
kid.my_method
#Output
"Kid is awesome"
"Mother is awesome"
Remote Repositories
git remote add origin git@github.com:onenight/try_git.git
Pushing Remotely
git push -u origin master
Pulling Remotely
git pull origin master
Cloning Repository
git clone repository_url
Differences
git diff HEAD
Add To Stage
git add file_name
Add To Last Commit
git commit --amend -m "message"
Staged Differences (stage)
git diff --staged
Resetting the Stage
git reset file_name
Resetting Commit To Stage
git reset --soft HEAD^
Resetting all changes
git reset --hard HEAD^
Undo
git checkout file_name
Branching Out
git branch branch_name
Switching Branches
git checkout branch_name
Branching & Switching
git checkout -b branch_name
Branch Clean Up
git branch -d branch_name
Removing All The Things
git rm '*.txt'
Merge Branch
git merge branch_name
Delete Branch
git branch -d branch name
Delete Branch (Force)
git branch -D branch name
Pushing Branch To Remote
git push origin branch_name
Listing All Remote Branches
git branch -r
Remote Show
git remote show origin
Deleting A Remote Branch
git push origin :branch_name
Clean Up Deleted Remote Branches
git remote prune origin
Listing Tags
git tag
Checkout Code At Tag
git checkout tag_name
Adding A New Tag
git tag -a tag_name -m "message"
Pushing Tags
git push --tags
Rebase
- Move all changes to master which are not in origin/master to a temporary area
- Run all origin/master commits
-
Run all commits in the temporary area, one at a time
git rebase
Configuration
Colorizing The Log
git config --global color.ui true
The Log
git log
One Line Log
git log --pretty-oneline
Log Format
- %ad - author date
- %an - author name
- %h - SHA hash
- %s - subject
-
%d - ref names
git log --pretty-format:"%h %ad- %s [%an]"
Patch(Showing Log Changes)
git log --oneline -p
Stats(Showing Log State)
git log --oneline --stat
Graph(Showing Log As Graph)
git log --oneline --graph
Date Ranges
git log --until=1.minute.ago
git log --since=1.day.ago
git log --since=1.hour.ago
git log --since=1.month.ago --until=2.weeks.ago
git log --since=2000-01-01 --until=2012-12-21
Uncommitted Changes
git diff HEAD
git diff HEAD^
git diff HEAD~5
git diff HEAD^..HEAD
Earlier Commits
git diff SHAs..SHAs
git diff master branch_name
Blame(Showing history of commits)
git blame file_name --date short
Excluding Files
- Adding folder/file_name to .git/info/exclude
Excluding From All Copies
- Adding pattern to .gitignore
Removing Files
git rm file_name
Untraching Files
git rm --cached file_name
Config
git config --global user.name "user name"
git config --global user.email "user email"
Use opendiff for merging conflicts
git config --global merge.tool opendiff
Local Config
Sets email for current repo
git config user.email
git config --list
Aliases
git config --global alias.mylog \
"log --pretty=format: '%h %s [%an]' --graph"
git config --global alias.lol \
"log --graph --decorate --pretty=online --abbrev-commit --all"
git config --global alias.st status
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.ci commit