21. 什麽是異常處理 Exception
異常處理 (Exception Handling) 是高階程式語言的一種用於處理異常狀況的流程機制,在 Ruby、JavaScript、Python、Java、Swift 等語言中都有這個功能。
哪些算是異常狀況呢?是指不在程序運行中預期會發生的事情,例如語法錯誤、發送 HTTP API 時網路不通、寫入檔案時硬盤已滿、數字除以零等等。
當發生異常時,程序會中斷跳開,跳到處理異常的代碼。如果沒有處理這段異常的代碼,整個程序就會終止(也就是軟體crash、app 閃退、網站看到500錯誤畫面)
在開發 Rails 時,每次你看到的紅色錯誤畫面,就是發生了一個異常錯誤(Exception)。
Ruby 語法說明: raise
讓我們實際看看異常處理的語法:
puts "Start"
raise "Errorrrr"
puts "Never execute"
這段代碼中,用 raise
會丟出一個異常(Exception),執行的結果是:
Start
Called
exception.rb:4:in `<main>': Errorrrr (RuntimeError)
註意到第三行的 puts "Never execute"
是沒有被執行的,執行到 raise
時程序就終止了。
raise 'An error has occured.'
等同於raise RuntimeError.new('An error has occured.')
,RuntimeError
是一個 Ruby 內建的預設異常物件,用來儲存關於這個異常的信息。Rails 還有內建其他不同的異常物件,詳見 Ruby API。
為什麽你好像很少用到 raise
這個功能呢?這是因為丟出 raise
的大多是 Ruby 本身或是我們使用的庫和框架之中,例如:
- 當你對一個物件調用一個不存在的方法時,Ruby 會丟出
NoMethodError
異常 - 在 Rails 中當URL找不到任何路由規則可以符合時,會丟出
Routing Error
異常
Ruby 語法說明: rescue
接下來讓我們加上處理異常的代碼:
puts "Start"
begin
puts "Called"
raise "Errorrrr"
puts "Never execute"
rescue => e
puts 'I am rescued.'
puts e.message
end
puts "Done"
從 begin
到 end
包住整個可能會丟出異常的代碼,然後用 rescue => e
可以捕捉到異常。執行的結果是:
Start
Called
I am rescued.
Errorrrr
Done
puts "Never execute"
這一行依然不會執行到,程式從 raise
後就跳去 resue
的部分,然後再繼續往後正常執行。
在 rescue
我們會寫如何去處理(拯救)異常,而 rescue => e
的 e
是個異常物件,會儲存關於這個異常的信息。
常見的 rescue
可能是顯示錯誤訊息,告訴用戶這個操作失敗了,然後程序回復正常繼續執行。
Ruby 語法: ensure
ensure
部分則是不管有沒有發生異常,都一定會執行到。例如:
begin
# do something
raise 'An error has occured.'
rescue => e
puts 'I am rescued.'
ensure
puts 'This code gets executed always.'
end
執行結果
I am rescued.
This code gets executed always.
Ruby 語法: 頂層異常捕獲
上述的 begin...rescue
語法,如果放在 def
方法定義中,則可以用以下的簡略寫法:
def 某個方法名稱
# do something
raise 'An error has occured.'
rescue => e
puts 'I am rescued.'
ensure
puts 'This code gets executed always.'
end
也就是可以省略掉原本異常處理的 begin
跟結尾的 end
。
22. 什麽是 callstack 和 backtrace
在一個複雜的軟體中,方法內會調用其他方法,然後方法又在調用其他方法,例如以下範例,c 方法調用 b 方法、b 方法內又調用 a 方法:
def a
puts "a"
raise "A error"
end
def b
puts "b"
a()
end
def c
puts "c"
b()
end
begin
c()
rescue => e
puts e.message
puts e.backtrace
ensure
puts "finally"
end
這種一層一層的關系,又叫做 callstack
。
其中 a 方法中丟出了異常,但是在 a 方法內並沒有 rescue,這個異常會一層一層往外拋出,直到某一層有 rescue 有本事捕捉這個例外。如果一直到到最外層都沒人能處理異常,那麽程序才會中斷。
在上述的 rescue
中,我們用 e.backtrace
可以列出調用的 callstack
關系,backtrace
的意思就是回朔當初的調用關系。
在 Rails 中如果發生異常,根據 development 模式或 production 模式,最外層有不同的異常處理策略:在開發時最外層的 rescue
會顯示錯誤的 backtrace,這樣可以幫助我們瞭解發生錯誤的來龍去脈:
這裡區分了 Application Trace、Framework Trace 和 Full Trace。預設顯示 Application Trace 也就是我們寫的代碼 backtrace,而 Framework Trace 則是發生在 Rails 框架內的 backtrace。
如果是 production 模式,預設的最外層 rescue
其實是顯示 500 錯誤畫面(也就是 public/500.html)。
23. 異常處理策略
現在你瞭解異常處理的語法了,我們回過頭來思考一下,當程序發生錯誤時會如何處理? 程序發生錯誤其實是蠻常見的事情,但是並不是每種錯誤都會用異常處理功能來解決。
回傳錯誤碼 vs. 拋出異常
事實上有兩種處理方式:第一種是回傳某個代表錯誤的值(例如 nil, true/false 或錯誤數字碼),第二種才是拋出異常 Exception。
例如從 Hash 或 Array 中取出一個值,如果該 key 不存在,其實也可以算是一種異常:
h = { :foo => "123" }
h[:bar] # 回傳 nil
h.fetch(:bar) # 丟出 KeyError 異常
其中 :bar
是一個不存在的 key,而 h[:bar]
預設回傳 nil
代表了此值不存在。但 Ruby 也提供另一個 Hash#fetch 用法,如果該 key 不存在,會丟出錯誤異常 KeyError
。同一件事情,Ruby 提供了兩種 API 方法,但有不同的異常處理策略。
在看 Ruby API 或是 gem 的 API 文檔時,你可以註意一下它們是如何處理異常情況的。例如在 rest-client gem 中(在 Web API 教程中,我們用這個 gem 來抓取第三方的信息),如果抓取失敗,就會拋出RestClient::ExceptionWithResponse 異常。
何時用拋出異常?
什麽時候會用錯誤碼?什麽時候會用拋出異常的方式呢?這得取決於這個錯誤是否在預期之內。拋出異常的意思像是: 這個異常這裡不知道怎麽處理,只好把燙手山芋拋出去,看哪一層有辦法處理。如果最後都沒有人可以處理,那程序只好 crash 了!
以 Rails 來說,存儲的 API 有兩種,一個是 save
一個是 save!
。差別是當 validation 驗證失敗時的處理方式不同。以下是大家比較熟悉的版本:
def create
@user = User.new params[:user]
if @user.save
redirect_to user_path(@user)
else
flash[:notice] = 'Unable to create user'
render :action => :new
end
end
這是改用異常處理的版本:
def create
@user = User.new params[:user]
@user.save!
redirect_to user_path(@user)
rescue ActiveRecord::RecordNotSaved
flash[:notice] = 'Unable to create user'
render :action => :new
end
在這兩個版本中,前者還是比較好的。這是因為用戶輸入不正確其實是很常見的事情,處理用戶輸入失敗,重新顯示表單也算是正常流程處理的一部分,所以用 if ... else ...
的寫法還比較清楚。改用異常處理的版本,代碼並不會比較清楚。
異常處理的初衷,是希望處理異常的代碼不要干擾正常代碼的可讀性,會發生異常的可能性很低,因此將處理異常的代碼放到最後面去。
甚至,不處理異常也是很常見的事情,例如在實戰應用「自訂列表順序」中,其中有個 reorder
action 是這樣的:
def reorder
@event = Event.find_by_friendly_id!(params[:id])
@event.row_order_position = params[:position]
@event.save!
end
在這個動作中,用了 save!
而不是 save
。這是因為我們沒有預期會發生 validation 失敗,我們也沒有打算要寫 validation 失敗了要怎麽處理。在這種情況下,我們就會改用 save!
而不是 save
。這樣如果真的萬一齣事了,程序就會中斷,用戶會看到 500 錯誤畫面,這個錯誤會被記錄在 log 之中讓我們程序員去檢查這個錯誤。如果你用 save
的話,如果失敗只會回傳 false
然後就繼續執行下去,用戶只會覺得很奇怪為什麽沒有存成功,但是卻沒有任何錯誤記錄。
24. 關於 Rails 的異常處理
在一節我們來回顧一下 Rails 是如何做異常處理的?
find
ActiveRecord 的 find
API 如果找不到數據,會丟 ActiveRecord::RecordNotFound
的例外。在 show action 我們經常用這個 API,因為如果真的找不到,程序就會中斷。
def show
@event = Event.find(params[:id])
end
在 development 開發模式中,你會看到 ActiveRecord::RecordNotFound 異常。在 production 上線模式,會顯示 404 頁面(public/404.html)
Rails 還有提供 find_by_欄位名稱
和 find_by_欄位名稱!
API,前者如果找不到數據,會回傳 nil
,後者則是拋出異常。
在實戰應用「自定義 Model 網址」中,我們新增了一個 friendly_id
欄位,然後 show action 改成:
def show
@event = Event.find_by_friendly_id!(params[:id])
end
當找不到數據時,就會拋出 ActiveRecord::RecordNotFound
異常程序中斷。
為什麽這裡偏好用拋出異常的策略呢?如果我們改用 Event.find_by_friendly_id(params[:id])
的話,找不到數據時會回傳 nil
,那麽 @event 變成 nil
程序繼續執行,一直進到 show.html.erb
裡面的某一行去顯示 @event.name
,然後就會碰到 nil.name
拋出異常是 NoMethodError
最後程序中斷。這時候要找到真正異常的原因就會多花一點時間。
我們希望在異常發生的第一時間就中斷程序(fail fast),而不是讓程序無聲地繼續執行下去,最後像一個未爆彈一樣最後莫名其妙地炸掉。
save 和 save!
這剛剛有提過,存儲有分 save 和 save!,更新有分 udpate 和 udpate!。
- 有搭配
if ... else
情況做處理用 save。這表示你會處理儲存失敗流程 - 沒有的話,用 save! 驚嘆號版本。這表示你認為 99% 應該會存成功,懶得處理存不成功。如果出錯會異常拋出
rescue_from
rescue_from 可以在 controller 中宣告 rescue_from
去救回特定的異常物件。例如:
class ApplicationController < ActionController::Base
rescue_from User::NotAuthorized, with: :deny_access # self defined exception
rescue_from ActiveRecord::RecordInvalid, with: :show_errors
rescue_from 'MyAppError::Base' do |exception|
render xml: exception, status: 500
end
private
def deny_access
...
end
def show_errors(exception)
exception.record.new_record? ? ...
end
end
這功能不太常用。你會發生其實我們不太常去處理 rescue
的情況,這是因為我們對於消費級軟體的魯棒性//抗變換性(Robust)的要求沒這麽高。如果異常發生了,只要能提示用戶並記錄下來就不錯了。在一些高魯棒性/抗變換性(Robust)要求的軟體中,例如醫療、金融企業等級軟體,則會進行重試(retry)或是切換備用系統。
安裝異常通知
在 production 上線環境中,如果用戶操作碰到異常會看到 500 錯誤畫面,並且在 log 中會紀錄下來。我們希望能有一些機制能夠主動通知我們程序員,好讓我可以 trace error、fixed bug 甚至在發生錯誤沒多久就可以通知苦主發生了什麽事情。
最基本我們可以安裝 Exception Notifier,這個套件會在發生例外時寄 email 通知。或是使用第三方服務,例如:
這些第三方服務可以在網站發生異常錯誤的時候自動將錯誤訊息收集起來,並且提供了還蠻不錯的後台可以瀏覽,還可以統計及追蹤異常處理的情況。免費的方案對於小網站就很夠用,非常推薦使用。
補充內容
- 我在 2014 年演講的投影片 Exception Handling: Designing Robust Software in Ruby