週末在思考一些 Ruby 特性可以應用的小技巧時,想到龍哥大概跟我講了三次以上的一個特性。
1fn = ->(other) { other == 1 }
2fn == 1
3# => false
4fn === 1
5# => true
剛好最近工作的專案上有個問題,似乎挺適合用這個技巧。
Proc 物件的 === 方法
我們在 Proc 的原始碼可以看到,Proc
類別被特別定義了 #===
方法,但不包括了 #==
方法,而這個 #===
方法又剛好指定成呼叫方法。
1rb_add_method(rb_cProc, rb_intern("==="), VM_METHOD_TYPE_OPTIMIZED,
2 (void *)OPTIMIZED_METHOD_TYPE_CALL, METHOD_VISI_PUBLIC);
同樣還有像是 #[]
也有類似的性質,簡單說就是 #call
方法的別名。這個使用方式也可以在 Ruby Doc 上找到。
因為這樣的特性,剛好有些情境就是不錯的應用情況。
Excel 的產生難題
最近花了不少時間在幫客戶處理 Excel 報表的生成功能,中間就發現了一個問題,因為客戶的報表有很多種(十幾種)而且需要的欄位又不太一樣,每種都寫一次程式的話其實是很沒有效率的(而且未來還希望能自訂報表的呈現)所以用設定檔方式設計是最適合的,不過產生的 Excel 檔案卻無法指定格式(Ex. 文字、日期)尤其是「日期」客戶每張報表的要求又不太一樣,這讓生成就變成一個難題。
1- name: KPI Report
2 columns:
3 - name: user_id
4 display: User ID
5 format: :integer
6 - name: signup_at
7 display: Signup At
8 format: :datetime
9 datetime: :customize
10 excel_format: '[$-409]yyyy-MM-dd;@'
11 - name: last_active_at
12 display: Last Active At
13 format: :datetime
14 datetime: :split
以上面這個 YAML 設定檔為例子,客戶可能會希望顯示 yyyy-MM-dd
或者 yyyy-MM-dd
+ HH:mm
之類的格式,所以在判斷 Excel 要提供怎樣的欄位的時候,就變得相對的複雜。
1def formats
2 @columns.map do |column|
3 next unless column[:format] == :datetime
4
5 case column[:datetime]
6 when :split then [@date_format, @time_format]
7 when :customize then format(column[:excel_format])
8 else
9 @date_format
10 end
11 end.flatten
12end
雖然上面的情況看起來還算單純,不過最後再加入其他不同類型的欄位判斷後,可能就會越來越複雜跟難以辨識。
在這個狀況下,利用 Proc
的 #===
特性就可能會是一個不錯的作法。
1is_datetime = ->(c) { c[:format] == :datetime }
2is_split_datetime = ->(c) { c[:format] == :datetime && c[:datetime] == :split }
3# ...
4
5case column
6when is_split_datetime then [@date_format, @time_format]
7when is_datetime then @date_format
8# ...
9end
不過這實際上並無法解決有很多複雜情況的時候,不過既然我們已經知道了 #===
在 case ... when
上可以發揮作用,那麼是否可以進一步封裝呢?
自訂物件
假設我們有個可以把設定檔轉成 ExcelColumn
物件的設計,也許可以像這樣實作。
先定義 ExcelColumn
物件,而且可以被做 Pattern Matching
(簡易版)然後在提供回傳對應的格式跟數值的機制。
1class ExcelColumn
2 def initialize(format = nil, **pattern, &block)
3 @name = name
4 @format = format
5 @pattern = pattern
6 @value_of = block
7 end
8
9 def ===(other)
10 @pattern.reduce(true) do |prev, (key, value)|
11 prev & (other[key] == value)
12 end
13 end
14
15 def format
16 return @format if @format.nil? || @format.is_a?(Symbol)
17
18 format(@format) # Customize Format
19 end
20
21 def value(name, row)
22 return row.send(name) if @value_of.nil?
23
24 @value_of.call(name, row)
25 end
26end
然後再設計 DSL 讓我們可以定義需要的格式。
1class ExcelGenerator
2 class << self
3 attr_reader :patterns
4
5 def pattern(format, **pattern, &block)
6 @patterns ||= []
7 @patterns << ExcelColumn.new(name, format, pattern, &block)
8 end
9 end
10
11 def columns
12 @columns ||=
13 @config.columns.map do |column|
14 [
15 column,
16 self.patterns.find { |pattern| pattern === column } || ExcelColumn.new
17 ]
18 end
19 end
20end
接下來就可以在實際使用時,像這樣去拓展 Excel 產生器,然後定義我們所需要的報表格式生成。
1class ExcelReportGnerator < ExcelGenerator
2 pattern format(:date), datetime: :date do |name, row|
3 row.send(name)&.to_datetime
4 end
5
6 # ...
7end
如此一來就能夠利用 DSL 跟 #===
的特性,針對我們需要有特殊格式的欄位挑選出來,然後給予特定的規則來產生對應的 Excel Cell 設定。
1insert_header(headers)
2
3formats = columns.map(&:last).map(&:format)
4rows.each do |row|
5 items = columns.map { |column, pattern| pattern.value_of(column[:name], row) }
6 insert_row(items, formats: formats)
7end
總結
最後實作的版本其實還是一個構想,畢竟這樣的情境是不常使用到的,不過在某些時候似乎又是個非常有用的小技巧。而這樣的構想是否適合這樣使用,以及能不能有更好的改進(例如一開始就直接定義好對應的欄位類型物件,而不要像這樣動態的定義)都還要再討論。
不過在 Ruby 中確實有不少有趣的應用技巧,多多挖掘的話其實能在不少不同的應用情況下用足夠簡單的方式實現,而不是繞一大圈去做。
更重要的是,這些技巧往往會是在時間緊迫下的輔助,有些功能透過這些技巧就可以很快地實現,而將時間投資在其他地方,而不是只能用一些折衷的方案暫時做好,之後再回來慢慢修改。
這是最近做專案的心得,因為客戶是新成立的部門很需要有一個實績,所以開發上難免偏向以開發進度為主。很多其實花時間思考後能做更好的部分,就這樣變成技術債了⋯⋯