機器戰警 RuboCop

有聽說過「編輯體例」嗎?拿起你手邊任何一本有經過出版社印製的書來看看,你應該可以發現:同一本書裡,不論是在排版格式上或是翻譯用語和專有名詞上,是有很高的一致性的。舉個簡單的例子,如果書中主角是「彼得」和「瑪莉」,即便編輯自身比較偏愛「彼德」和「瑪麗」的用法,也必須按照出版社要求的編輯體例,將用語統一為「彼得」和「瑪莉」。因為有共同的標準,同一間出版社下,就算是不同的編輯經手的不同作品,在排版和用語上也會有很高的一致性,例如凡是日期都會統一使用「六月四日」這樣的格式,不會印成「6 月 4 日」。

程式的世界裡也有類似的概念,我們稱之為 coding style。諸如怎麼縮排、括號怎麼斷行(或不斷行)、變數如何命名、判斷式的寫法、是否有行數限制等,每個人都有各自的偏好。這裡面有些是業界公認的 best practice,也有些是見仁見智的宗教戰爭,但無論如何,在團隊協作的情境下,有一個「大家可以接受並願意遵循的 coding style」作為開發的基礎,是相當重要的事情。

Ruby 圈有幾個比較有名的 style guide(字母序)供大家參考:

對於工程師來說,「程式碼的風格統一」這種事情,當然就應該交給工具來處理囉。而在 Ruby 圈裡,最被廣泛使用的檢查工具就是本文要介紹的 RuboCop。RuboCop 大約在 2012 年前後問世,也算是歷史悠久的開源專案了,但時至今日依然還沒有推出 1.0 的版本,官方文件也很大方地承認常會有向後相容的問題,要大家自己注意使用的版號 XD

那就開始吧。因為沒有現成的例子,就順便工商服務一下,用敝公司的開源工具 bankai 產生一個新的 Rails 專案:

$ gem install bankai
$ bankai demo

馬上來執行看看:

$ cd demo
$ rubocop
Inspecting 40 files
........................................

40 files inspected, no offenses detected

RuboCop 檢查了 40 個檔案,沒有發現問題 ,打完收工 🎉 呃不是啦 XD 再仔細看一下,會發現專案下有這兩個設定檔:

$ ls .rubocop*
.rubocop.yml      .rubocop_todo.yml

.rubocop.yml 一看就是善良市民可以先放一邊, 倒是 .rubocop_todo.yml 看起來就很可疑,馬上打開來看看:

# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2020-05-13 11:13:56 +0800 using RuboCop version 0.81.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.

RuboCop 既然是用來檢查 coding style 的工具,自然也是依據某個 style guide 作為標準,而 RuboCop 參照的則是前文提到的社群版本。由於 Rails 和 RuboCop 是由不同團隊開發的,雙方的「出廠預設值」會有衝突也是在情理之中,所以並不是「RuboCop 沒有意見」,而是 bankai 預先把這些「已知不合」的狀況先隔離在 todo 裡,再讓團隊自行判斷後續該如何處理。

這裡必須再強調一次:我們在專案中導入 RuboCop 或其他 linter,並不是為了要追求「考試一百分」這樣的價值,而是要讓團隊成員在協作時能有共同遵循的標準。也就是說,在團隊成員有共識的前提之下,RuboCop 的結果只是參考,不需要盲從,預設值可以調整,必要時甚至也可以忽略。

那我們就順手改一下 todo 裡的東西吧!

# Offense count: 1
# Configuration parameters: EnforcedStyle.
# SupportedStyles: slashes, arguments
Rails/FilePath:
  Exclude:
    - 'config/environments/development.rb'

把這一條規則(我們稱之為 cop)註解起來後再執行一次,就會乖乖噴錯了:

$ rubocop
Inspecting 40 files
.................C......................

Offenses:

config/environments/development.rb:19:6: C: Rails/FilePath: Please use Rails.root.join('path/to') instead.
  if Rails.root.join('tmp', 'caching-dev.txt').exist?
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

40 files inspected, 1 offense detected

翻成白話文就是,RuboCop 幫我們偵測到了「未遵循 Rails/FilePath 規則」的原始碼,還很親切地告訴我們是在哪個檔案的哪一行,以及修改的建議。我們通常有以下選項:

  • 就讓它噴錯,什麼都不做
  • 照著 RuboCop 的建議修改程式碼
  • 調整規則,不改原始碼
  • 保留規則,但將這段程式碼排除在外

一般來說,我們會把 .rubocop_todo.yml 中的 cop 逐一註解掉,處理過後使之不再報錯,直到整個 todo 可以被安全移除為止。

照建議修改

在這個例子裡,程式碼的修改很單純,照著建議去改就可以了:

 if Rails.root.join('tmp/caching-dev.txt').exist?

改過後再執行一次,確認沒有問題:

$ rubocop
Inspecting 40 files
........................................

40 files inspected, no offenses detected

不過實務上,尤其是在比較有歷史的 code base 裡,是很有機會遇到「只是換個寫法,測試就掛了」「要改就只能整段重寫」等狀況的。因為這真的很重要所以要說第三次:RuboCop 這一類的工具只是幫助我們找出潛在的風險,它給的建議也不是「標準答案」(寫程式本身就沒有標準答案這回事),「怎麼做才是最好的?」最終還是要用自己的專業做判斷。

調整規則

反過來說,如果大家覺得 RuboCop 抱怨的程式碼沒有問題,那就來解決提出問題的人調整 RuboCop 的設定檔就好。一種作法是直接放大絕,把整個規則 disable 掉:

# .rubocop.yml

Rails/FilePath:
  Enabled: false

逃避雖然可恥但是有用?這雖然也是一種解法,但比較好的作法是去修改規則的內容,而不是把規則整個拿掉。參照官方文件可以得知 Rails/FilePath 預設的樣式是 path/to/file,如果希望統一成 ('path', 'to', 'file') 的格式的話,就要另外指定為:

# .rubocop.yml

Rails/FilePath:
  EnforcedStyle: arguments

修改後再執行一次看看有沒有問題:

$ rubocop
Inspecting 40 files
...................................C....

Offenses:

spec/rails_helper.rb:15:5: C: Rails/FilePath: Please use Rails.root.join('path', 'to') instead.
Dir[Rails.root.join('spec/support/**/*.rb')].sort.each { |f| require f }
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

40 files inspected, 1 offense detected

因為規則改了,所以原本沒有問題的地方變成有問題了。照理說,因為規則改了,我們應該要去改 rails_helper.rb 的程式碼才對,但因為正好可以拿來展示語法,我們就順勢來用排除法吧。

排除特定程式碼

當我們希望「這個 cop 有其必要」,但「在這個特例下可以排除」的時候,可以用 inline 的方式對程式段落做 disable:

# rubocop:disable Rails/FilePath
Dir[Rails.root.join('spec/support/**/*.rb')].sort.each { |f| require f }
# rubocop:enable Rails/FilePath

如果是「這個 cop 有其必要」,但「這些目錄下的檔案可以不用遵守」的話,也可以直接在設定檔裡做排除。例如在 .rubocop.yml 中,我們已經預設讓 db migration 和 rake task 之類的程式碼,可以不受 metrics 條件的限制:

# .rubocop.yml

Metrics:
  Exclude:
    - db/migrate/*
    - Rakefile
    - Gemfile
    - config/**/*
    - spec/**/*
    - lib/tasks/**/*

自動修改

RuboCop 也支援自動修改。要注意的是,並不是每一個 cop 都有支援(請自己看文件確認),就算支援,一般也不建議用在單純語法或排版統一以外的地方。Rails 開發者最常用到的可能是這個 XD

$ rubocop --auto-correct --only Style/FrozenStringLiteralComment

執行下去後,RuboCop 就會幫我們補好煩人的這一句:

# frozen_string_literal: true

結語

善用工具,可以讓團隊協作的品質更整齊,但要小心不要陷入追求標準答案和滿分的陷阱,不然很容易本末倒置。Ruby 圈類似的檢測工具還有:

  • reek: 偵測可能的 code smell
  • rails_best_practices: 這個在做什麼應該很明顯
  • flay: 偵測可能的重覆程式碼
  • SandiMeter: 檢查程式碼是否符合 Sandi Metz 提倡的四大原則

有機會的話都可以拿來玩看看 :)

參考資料

各個 cop 的詳細設定方式和範例都可以在官方文件裡查到:

(Photo Credit: “RoboCop” by Andrew Milligan sumo is licensed under CC BY 2.0)