acts-as-taggable-on 是 Rails 中處理標籤功能的常用套件,而利用 PostgreSQL 特殊的 Array 型別也能實現類似的功能,像是 acts-as-taggable-array-on 套件就是以 PostgreSQL Array 實現。本篇文章將比較下面幾種解決方案:
- acts-as-taggable-on
- acts-as-taggable-array-on
- pg_taggable
情境
假設有兩個 model:Post 和 User,Post 有 tags 屬性,並屬於某 User。可能常見的功能:
- 依照 tags 來搜索 Post。
- 取得某個 User 的 tags。
- 統計某個 User 的 tags 次數。(標籤雲)
功能
acts-as-taggable-on
設定
acts-as-taggable-on 用另外兩張表來儲存標籤,一次性建好資料表,之後只要在 model 中設定就好:
1 2 3 4 5
| class Post < ApplicationRecord belongs_to :user
acts_as_taggable_on :tags end
|
為了實現照 User 撈出 Tag,User Model 需要額外設定 tagger。
1 2 3 4 5
| class User < ApplicationRecord has_many :posts
acts_as_tagger end
|
要注意如果事後才加上設定的話,前面的資料是不會有 tagger 的紀錄。
存取標籤
沒有需要紀錄 Tagger 的情況下是這樣直接使用:
1 2 3 4
| post.tag_list = 'food, travel, technology' post.save post.tag_list
|
但使用了 acts_as_tagger 之後必續這樣使用:
1 2 3 4 5
| user.tag(post, with: 'food, travel, technology', on: :tags) post.owner_tags_on(user, :tags)
post.all_tags_list
|
邏輯有點複雜。因為 Tagger 功能其實是另一種用途,這邊拿來做單純的區分 user 的標籤。
搜索
利用 tagged_with 來進行各種條件搜索
1 2 3 4 5 6 7 8 9
| Post.tagged_with(%w[food travel technology], on: :tags) Post.tagged_with(%w[food travel technology], on: :tags, match_all: true) Post.tagged_with(%w[food travel technology], on: :tags, any: true) Post.tagged_with(%w[food travel technology], on: :tags, exclude: true) Post.tagged_with(%w[food travel], on: :tags).tagged_with(%w[technology], on: :tags, exclude: true)
Post.tagged_with(%w[food], on: :tags, wild: :suffix) Post.tagged_with(%w[food], on: :tags, wild: :prefix)
|
Ransack 搜索
Ransack 官方文件有說明如何搜索 acts-as-taggable-on 的資料,但在新版的 Ransack 中存在 Bug 不能直接使用。需要做一些處理,在 Post 中加入:
1 2 3 4 5 6 7 8 9 10 11
| def self.ransackable_attributes(auth_object = nil) %w[tags_name] end
def self.ransackable_associations(auth_object = nil) %w[tags] end
ransacker :tags_name do |parent| Arel::Nodes::SqlLiteral.new("tags.name") end
|
然後 ransack 就可以像一般欄位一樣查詢
1 2
| Post.joins(:tags).ransack(tags_name_eq: 'tag').result(distinct: true) Post.joins(:tags).ransack(tags_name_in: %w[tag1 tag2]).result(distinct: true)
|
標籤列表
1 2 3 4 5 6 7 8 9 10
| user.owned_tags
user.owned_tags.where(taggings: { taggable_type: 'Post' })
user.owned_tags.pluck(:name)
|
標籤統計
1 2 3 4 5 6 7 8 9 10 11
| Post.all.tag_counts_on(:tags)
Post.where(user_id: 1).tag_counts_on(:tags)
Post.where(user_id: 1).tag_counts_on(:tags).each do |record| end
|
acts-as-taggable-array-on
設定
因為是利用 Postgresql Array,所以要建立相關欄位和索引
1 2 3 4 5 6 7 8 9
| create_table :posts do |t| t.bigint :user_id t.string :tags, array: true, default: []
t.timestamps
t.index :user_id t.index :tags, using: "gin" end
|
接著在 Model 加入
1 2 3 4 5
| class Post < ApplicationRecord belongs_to :user
taggable_array :tags end
|
使用 string 型別的話會區分大小寫,如果想要不分大小寫要改用 citext。
存取標籤
把它當成一般陣列處理就可以了
1 2 3 4 5
| post.tags = %w[food travel technology] post.save
post.tags
|
他不會過濾重複項目
1 2 3 4 5
| post.tags = %w[food food] post.save
post.tags
|
搜索
會生成對應的 class methods
with_any_#{tag_name},相當於 tagged_with(any: true)with_all_#{tag_name},相當於 tagged_with 預設條件without_any_#{tag_name},相當於 tagged_with(exclude: true)without_all_#{tag_name},acts-as-taggable-on 沒有對應功能
1 2 3 4 5
| Post.with_any_tags(%w[food travel technology]) Post.with_all_tags(%w[food travel technology]) Post.without_any_tags(%w[food travel technology]) Post.without_all_tags(%w[food travel technology]) Post.with_all_tags(%w[food travel]).without_any_tags(%w[technology])
|
沒有 match_all 和模糊搜索的功能。
Ransack 搜索
可以直接使用 scopes 來搜索,在 Post 中加入:
1 2 3
| def self.ransackable_scopes(auth_object = nil) %w[with_all_tags] end
|
然後可以直接搜索
1 2
| Post.ransack(with_all_tags: 'tag').result Post.ransack(with_all_tags: 'tag1,tag2').result
|
標籤列表
會生成對應的 class methods
1 2 3 4 5 6 7 8
| Post.all_tags
Post.all_tags { where(user_id: 1) }
|
可以看到條件篩選的寫法還蠻奇怪的。利用這個可以自己手動實現模糊搜索:
1 2
| tags = Post.all_tags.select { |t| t.start_with?('food') } Post.without_any_tags(tags)
|
標籤統計
會生成對應的 class methods
1 2 3 4 5 6 7 8
| Post.tags_cloud
Post.tags_cloud { where(user_id: 1) }
|
一樣要下條件篩選的寫法還蠻奇怪的。
pg_taggable
設定
和 acts-as-taggable-array-on 一樣 migration 要加欄位,參考上面。Model 則是改用 taggable
1 2 3 4 5
| class Post < ApplicationRecord belongs_to :user
taggable :tags end
|
存取標籤
和 acts-as-taggable-array-on 一樣,把它當成一般陣列處理就可以了,參考上面。
但預設會過濾重複項目
1 2 3 4 5
| post.tags = %w[food food] post.save
post.tags
|
如果想讓他有重複項目,可以改設定
1
| taggable :tags, unique: false
|
搜索
採用直接用 where 的方式來查詢,會產生以下查詢方式:
any_#{tag_name},相當於 tagged_with(any: true)all_#{tag_name},相當於 tagged_with 預設條件#{tag_name}_in,acts-as-taggable-on 沒有對應功能#{tag_name}_eq,相當於 tagged_with(match_all: true)
1 2 3 4 5
| Post.where(any_tags: %w[food travel technology]) Post.where(all_tags: %w[food travel technology]) Post.where(tags_in: %w[food travel technology]) Post.where(tags_eq: %w[food travel technology]) Post.where(all_tags: %w[food travel]).where.not(any_tags: %w[travel])
|
也可以直接用 not。
1 2
| Post.where.not(any_tags: %w[food travel technology])
|
不支援模糊搜索,但多了一些不同的搜索方式。
Ransack 搜索
可以直接使用 scopes 來搜索,在 Post 中加入:
1 2 3
| def self.ransackable_scopes(auth_object = nil) %w[all_tags] end
|
然後可以直接搜索
1 2
| Post.ransack(all_tags: 'tag').result Post.ransack(all_tags: 'tag1,tag2').result
|
標籤列表
會生成對應的 class methods
#{tag_name},將 Array 展開回傳,可進一步使用uniq_#{tag_name}
1 2 3 4 5 6 7 8 9 10 11
| Post.tags
Post.tags.distinct.pluck(:tag)
Post.uniq_tags
Post.where(user_id: 1).uniq_tags
|
利用這個可以自己手動實現模糊搜索:
1 2
| tags = Post.tags.where("tag LIKE ?", "food%").distinct.pluck(:tag) Post.where(any_tags: tags)
|
標籤統計
會生成對應的 class methods
1 2 3 4 5 6
| Post.count_tags
Post.where(user_id: 1).count_tags
|
效能
接著來測試一下效能,這邊我建立了一個測試專案,跑出來的結果大概是這樣 (下面整理過排版):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| --------------------------------- benchmark:create acts-as-taggable-on 0.021 (± 0.0%) i/s (47.50 s/i) - 1.000 in 47.499407s acts-as-taggable-array-on 0.487 (± 0.0%) i/s (2.05 s/i) - 3.000 in 6.161091s pg_taggable 0.484 (± 0.0%) i/s (2.07 s/i) - 3.000 in 6.197196s --------------------------------- benchmark:as_json acts-as-taggable-on 0.055 (± 0.0%) i/s (18.21 s/i) - 1.000 in 18.208944s acts-as-taggable-array-on 0.983 (± 0.0%) i/s (1.02 s/i) - 6.000 in 6.152531s pg_taggable 1.034 (± 0.0%) i/s (967.47 ms/i) - 6.000 in 5.809804s --------------------------------- benchmark:owned_tags acts-as-taggable-on 2.471 (±40.5%) i/s (404.74 ms/i) - 12.000 in 5.234341s acts-as-taggable-array-on 3.249 (± 0.0%) i/s (307.82 ms/i) - 16.000 in 5.031208s pg_taggable 3.323 (± 0.0%) i/s (300.89 ms/i) - 17.000 in 5.165599s --------------------------------- benchmark:count acts-as-taggable-on 1.070 (± 0.0%) i/s (934.87 ms/i) - 6.000 in 5.621106s acts-as-taggable-array-on 3.061 (± 0.0%) i/s (326.66 ms/i) - 16.000 in 5.250155s pg_taggable 3.273 (± 0.0%) i/s (305.55 ms/i) - 17.000 in 5.215984s --------------------------------- benchmark:like acts-as-taggable-on 6.360 (±78.6%) i/s (157.22 ms/i) - 26.000 in 5.050598s acts-as-taggable-array-on 0.807 (± 0.0%) i/s (1.24 s/i) - 5.000 in 6.217276s pg_taggable 0.928 (± 0.0%) i/s (1.08 s/i) - 5.000 in 5.392569s --------------------------------- benchmark:all_tags[1] acts-as-taggable-on 4.025 (±24.8%) i/s (248.46 ms/i) - 20.000 in 5.054010s acts-as-taggable-array-on 10.377 (±19.3%) i/s (96.37 ms/i) - 50.000 in 5.070872s pg_taggable 10.658 (±18.8%) i/s (93.83 ms/i) - 51.000 in 5.045711s --------------------------------- benchmark:all_tags[5] acts-as-taggable-on 0.463 (± 0.0%) i/s (2.16 s/i) - 3.000 in 6.480544s acts-as-taggable-array-on 15.917 (±50.3%) i/s (62.83 ms/i) - 66.000 in 5.082511s pg_taggable 11.104 (±27.0%) i/s (90.06 ms/i) - 52.000 in 5.009450s --------------------------------- benchmark:all_tags[10] acts-as-taggable-on 0.117 (± 0.0%) i/s (8.53 s/i) - 1.000 in 8.533582s acts-as-taggable-array-on 17.848 (±50.4%) i/s (56.03 ms/i) - 76.000 in 5.207719s pg_taggable 11.504 (±17.4%) i/s (86.93 ms/i) - 56.000 in 5.007423s --------------------------------- benchmark:any_tags[1] acts-as-taggable-on 3.362 (± 0.0%) i/s (297.47 ms/i) - 17.000 in 5.135870s acts-as-taggable-array-on 5.745 (±17.4%) i/s (174.05 ms/i) - 28.000 in 5.024439s pg_taggable 6.228 (±32.1%) i/s (160.56 ms/i) - 30.000 in 5.149333s --------------------------------- benchmark:any_tags[5] acts-as-taggable-on 2.637 (±37.9%) i/s (379.24 ms/i) - 13.000 in 5.161145s acts-as-taggable-array-on 1.807 (± 0.0%) i/s (553.50 ms/i) - 10.000 in 5.558059s pg_taggable 1.851 (± 0.0%) i/s (540.14 ms/i) - 10.000 in 5.467386s --------------------------------- benchmark:any_tags[10] acts-as-taggable-on 2.171 (±46.1%) i/s (460.58 ms/i) - 11.000 in 5.311140s acts-as-taggable-array-on 1.490 (± 0.0%) i/s (671.32 ms/i) - 8.000 in 5.378806s pg_taggable 1.509 (± 0.0%) i/s (662.76 ms/i) - 8.000 in 5.308514s --------------------------------- benchmark:exclude_tags[1] acts-as-taggable-on 2.572 (± 0.0%) i/s (388.73 ms/i) - 13.000 in 5.167899s acts-as-taggable-array-on 1.269 (± 0.0%) i/s (787.84 ms/i) - 7.000 in 5.544178s pg_taggable 1.323 (± 0.0%) i/s (755.66 ms/i) - 7.000 in 5.306183s --------------------------------- benchmark:exclude_tags[5] acts-as-taggable-on 2.077 (± 0.0%) i/s (481.49 ms/i) - 11.000 in 5.325972s acts-as-taggable-array-on 1.112 (± 0.0%) i/s (899.34 ms/i) - 6.000 in 5.440142s pg_taggable 1.171 (± 0.0%) i/s (854.11 ms/i) - 6.000 in 5.129889s --------------------------------- benchmark:exclude_tags[10] acts-as-taggable-on 1.956 (±51.1%) i/s (511.35 ms/i) - 10.000 in 5.371704s acts-as-taggable-array-on 0.998 (± 0.0%) i/s (1.00 s/i) - 5.000 in 5.023394s pg_taggable 1.014 (± 0.0%) i/s (985.74 ms/i) - 6.000 in 5.918865s --------------------------------- benchmark:match_all_tags[1] acts-as-taggable-on 1.495 (± 0.0%) i/s (668.81 ms/i) - 8.000 in 5.379761s pg_taggable 7.993 (±37.5%) i/s (125.11 ms/i) - 37.000 in 5.061658s --------------------------------- benchmark:match_all_tags[5] acts-as-taggable-on 0.396 (± 0.0%) i/s (2.53 s/i) - 2.000 in 5.051440s pg_taggable 17.332 (±69.2%) i/s (57.70 ms/i) - 54.000 in 5.137717s --------------------------------- benchmark:match_all_tags[10] acts-as-taggable-on 0.112 (± 0.0%) i/s (8.90 s/i) - 1.000 in 8.897128s pg_taggable 14.906 (±73.8%) i/s (67.09 ms/i) - 48.000 in 5.091181s ---------------------------------
|
可以看出 Postgresql Array 在大部分情況效能較好,acts-as-taggable-on 效能輸比較多的是:
- 新增
- 讀取
- 預設的搜索模式 all_tags
- 搜索模式 match_all_tags
- 統計
前三個還是常用的情況。
acts-as-taggable-on 效能較好的情況是比較不常用的模糊搜索。
總結
做成表格進行比較
| 項目 | acts-as-taggable-on | acts-as-taggable-array-on | pg_taggable |
|---|
| 功能完整 | O | X | O |
| 使用方便 | △ | △ | O |
| 效能 | X | O | O |
| 兼容性 | O | X | X |
| Ransack 支援 | △ | O | O |
- 後兩者是限定 Postgresql 才能使用,如果確定使用 Postgresql 的情況下,
pg_taggable 可能是個不錯的選擇。 - 其實原本直接想用
acts-as-taggable-array-on,但功能不太好用,結果自己寫了 pg_taggable。