acts-as-taggable-on 是 Rails 中處理標籤功能的常用套件,而利用 PostgreSQL 特殊的 Array 型別也能實現類似的功能,像是 acts-as-taggable-array-on 套件就是以 PostgreSQL Array 實現。本篇文章將比較下面幾種解決方案:

  1. acts-as-taggable-on
  2. acts-as-taggable-array-on
  3. pg_taggable

情境

假設有兩個 model:PostUserPosttags 屬性,並屬於某 User。可能常見的功能:

  1. 依照 tags 來搜索 Post。
  2. 取得某個 User 的 tags。
  3. 統計某個 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
# => ["food", "travel", "technology"]

但使用了 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 全部的 tags
user.owned_tags

# user 在 posts 中的 tags
user.owned_tags.where(taggings: { taggable_type: 'Post' })

# 會回傳類似
# => [#<ActsAsTaggableOn::Tag:0x00000001210b5000 id: 66, name: "tag65", created_at: "2025-04-11 09:10:24.285143000 +0000", updated_at: "2025-04-11 09:10:24.285143000 +0000", taggings_count: 100>, ...
# 可以用 pluck 取出標籤
user.owned_tags.pluck(:name)

標籤統計

1
2
3
4
5
6
7
8
9
10
11
# all tags
Post.all.tag_counts_on(:tags)

# user_id 1 的 tags 統計
Post.where(user_id: 1).tag_counts_on(:tags)

# 會回傳類似
# => [#<ActsAsTaggableOn::Tag:0x00000001210bc6c0 id: 34, name: "tag33", created_at: "2025-04-11 09:10:22.523571000 +0000", updated_at: "2025-04-11 09:10:22.523571000 +0000", taggings_count: 100, count: 10>
Post.where(user_id: 1).tag_counts_on(:tags).each do |record|
# record.name, record.count
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
# => ["food", "travel", "technology"]

他不會過濾重複項目

1
2
3
4
5
post.tags = %w[food food]
post.save

post.tags
# => ["food", "food"]

搜索

會生成對應的 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

  • all_#{tag_name}
1
2
3
4
5
6
7
8
# all tags
Post.all_tags

# user_id 1 在 posts 中的 tags
Post.all_tags { where(user_id: 1) }

# 會直接輸出標籤字串陣列
# => ["food", "travel", "technology"]

可以看到條件篩選的寫法還蠻奇怪的。利用這個可以自己手動實現模糊搜索:

1
2
tags = Post.all_tags.select { |t| t.start_with?('food') }
Post.without_any_tags(tags)

標籤統計

會生成對應的 class methods

  • #{tag_name}_cloud
1
2
3
4
5
6
7
8
# all tags
Post.tags_cloud

# user_id 1 在 posts 中的 tags 統計
Post.tags_cloud { where(user_id: 1) }

# 會直接輸出統計 Hash
# => {"tag0"=>100,

一樣要下條件篩選的寫法還蠻奇怪的。

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
# => ["food"]

如果想讓他有重複項目,可以改設定

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
# 相當於 tagged_with(exclude: true)
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
# => #<ActiveRecord::Relation [#<Post tag: "food", id: nil>, #<Post tag: "travel", id: nil>, #<Post tag: "travel", id: nil>, #<Post tag: "technology", id: nil>]>

Post.tags.distinct.pluck(:tag)
# => ["food", "travel", "technology"]

# 相當於 Post.tags.distinct.pluck(:tag)
Post.uniq_tags

# user_id 1 在 posts 中的 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

  • count_#{tag_name}
1
2
3
4
5
6
# 相當於 Post.tags.group(:tag).count
Post.count_tags
# => {"food"=>1, "travel"=>2, "technology"=>1}

# user_id 1 在 posts 中的 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-onacts-as-taggable-array-onpg_taggable
功能完整OXO
使用方便O
效能XOO
兼容性OXX
Ransack 支援OO
  • 後兩者是限定 Postgresql 才能使用,如果確定使用 Postgresql 的情況下,pg_taggable 可能是個不錯的選擇。
  • 其實原本直接想用 acts-as-taggable-array-on,但功能不太好用,結果自己寫了 pg_taggable