Gem ActionStore - 一步到位的 Like, Follow, Star, Block ... 等动作的解决方案

huacnlee for Rails Engine Gem · 2017年02月08日 · 最后由 tinyfeng 回复于 2021年01月15日 · 12202 次阅读
本帖已被管理员设置为精华贴

各类系统总会或多或少的需要一些赞、收藏、关注/订阅等功能。

每次我们都要重新开发么?不!那不是 DRY 的风格。

鉴于多次这类功能的实践总结,我发布了 ActionStore,一个可以帮你解决这类需求的库。

介绍

ActionStore 采用 Active Record 多态关联(Polymorphic Association)的方式存储各种类型的动作数据,例如:赞、喜欢、收藏、关注、订阅、屏蔽(靠你的想象,还可以干更多的事情)等等,各类 User -> Target 的场景。

因此你无需对这些动作重复重建表、Model,也不用重复写 like_topic, unlike_topic, @topic.like_by_users, @user.like_topics 之类的逻辑函数。一切都由 ActionStore 内部帮你处理掉。

项目地址:

https://github.com/rails-engine/action-store

适用的场景

  • 赞(Like) - Post / Topic / Reply / Comment / Issue ...
  • 订阅 (Subscribe) - Post / Category
  • 好友关注 (Follow) - User
  • 收藏 (Star) - Post / Topic / Reply

基本结构

Field 含义
action_type action 类型 [like, watch, follow, star, favorite]
user_type, user_id 多态的方式关联 User models [User, Person, Member]
target_type, target_id 多态的方式关联 Target models [User, Post, Comment]

使用方法

gem 'action-store'

and run bundle install

$ rails g action_store:install
create  config/initializers/action_store.rb
migration 20170208024704_create_actions.rb from action_store

将会把 Migration 放入你的项目里面,最后执行 rails db:migrate

声明 Actions

你需要在 User model 里面使用 action_store 来定义 actions:

app/models/user.rb

class User < ActiveRecord::Base
  # action_store <action_type>, <target>, opts

  # 例如
  action_store :like, :post, counter_cache: true
  action_store :star, :post, counter_cache: true, user_counter_cache: true
  action_store :follow, :post
  action_store :like, :comment, counter_cache: true
  action_store :follow, :user, counter_cache: 'followers_count', user_counter_cache: 'following_count'
end

Convention Over Configuration:

action, target Target Model Target counter_cache_field User counter_cache_field Target has_many User has_many
action_store :like, :post Post has_many :like_by_user_actions, has_many :like_by_users has_many :like_post_actions, has_many :like_posts
action_store :like, :post, counter_cache: true Post likes_count has_many :like_by_user_actions, has_many :like_by_users has_many :like_post_actions, has_many :like_posts
action_store :star, :project, class_name: 'Repository' Repository stars_count star_projects_count has_many :star_by_user_actions, has_many :star_by_users
action_store :follow, :user User follows_count follow_users_count has_many :follow_by_user_actions, has_many :follow_by_users has_many :follow_user_actions, has_many :follow_users
action_store :follow, :user, counter_cache: 'followers_count', user_counter_cache: 'following_count' User followers_count following_count has_many :follow_by_user_actions, has_many :follow_by_users has_many :follow_user_actions, has_many :follow_users

Counter Cache

ActionStore 内建 Counter Cache 的逻辑(实际上目前是完整统计的,以确保精确),如果你需要,你得在 Target, User 的表里面增加相应的字段。

add_column :users, :star_posts_count, :integer, default: 0
add_column :users, :followers_count, :integer, default: 0
add_column :users, :following_count, :integer, default: 0

add_column :posts, :likes_count, :integer, default: 0
add_column :posts, :stars_count, :integer, default: 0

add_column :comments, :likes_count, :integer, default: 0

于是你就可以这么用了:

@user -> like @post

irb> User.create_action(:like, target: @post, user: @user)
true
irb> @user.create_action(:like, target: @post)
true
irb> @post.reload.likes_count
1

@user1 -> unlike @user2

irb> User.destroy_action(:follow, target: @post, user: @user)
true
irb> @user.destroy_action(:like, target: @post)
true
irb> @post.reload.likes_count
0

Check @user1 is liked @post

irb> action = User.find_action(:follow, target: @post, user: @user)
irb> action = @user.find_action(:like, target: @post)
irb> action.present?
true

User follow cases:

# @user1 -> follow @user2
@user1.create_action(:follow, target: @user2)
@user1.reload.following_count => 1
@user2.reload.followers_count_ => 1
@user1.follow_user?(@user2) => true
# @user2 -> follow @user1
@user2.create_action(:follow, target: @user1)
@user2.follow_user?(@user1) => true
# @user1 -> follow @user3
@user1.create_action(:follow, target: @user3)
# @user1 -> unfollow @user3
 @user1.destroy_action(:follow, target: @user3)

使用不同的表来存储 actions

since 1.1.0

某些时候,我们可能期望将 Action Store 存储到不同的表里面,比如 点赞已读 这类场景数据规模可能会很大,这种时候我们可能有期望将这些动作存储到独立的表里面。

自从 action-store 1.1.0 版本开始,我们可以用 action_class_name 来定义 action 动作存储的 Model。

你需要创建一个新的 Model 和 Migration

$ rails g migration create_likes

然后修改 Migration 文件,将它的字段、索引保持和 actions 表一致,就像这样:

class CreateLikes < ActiveRecord::Migration[6.1]
  def change
    create_table :likes do |t|
      t.string :action_type, null: false
      t.string :action_option
      t.string :target_type
      t.bigint :target_id
      t.string :user_type
      t.bigint :user_id

      t.timestamps
    end

    add_index :likes, %i[user_type user_id action_type]
    add_index :likes, %i[target_type target_id action_type]
    add_index :likes, %i[action_type target_type target_id user_type user_id], unique: true, name: :uk_likes_target_user
  end
end

创建你的 Like model,并继承 Action model:

# app/model/like.rb`
class Like < Action
  # 指定表名称
  self.table_name = "likes"
end

修改 action_store 的定义,使用 action_class_name 指定使用 Like model 来存储 actions:

# app/models/user.rb
class User < ActiveRecord::Base
  action_store :like, :post, counter_cache: true, action_class_name: "Like"
  action_store :like, :comment, counter_cache: true, action_class_name: "Like"
end

现在 user.like_post, user.like_comment 这些动作将会存储到 likes 表了。

内建关联关系和函数

当你用 action_store 定义 actions 以后,ActionStore 会自动创建 Target 和 User 的多对多关联关系。

例如:

class User < ActiveRecord::Base
  action_store :like, :post
  action_store :block, :user
end

会定义 Many-to-Many 关系:

  • For User model will defined: <action>_<target>s (like_posts)
  • For Target model will defined: <action>_by_users (like_by_users)
# for User model
has_many :like_post_actions
has_many :like_posts, through: :like_post_actions
## as user
has_many :block_user_actions
has_many :block_users, through: :block_user_actions
## as target
has_many :block_by_user_actions
has_many :block_by_users, through: :block_by_user_actions

# for Target model
has_many :like_by_user_actions
has_many :like_by_users, through: :like_user_actions

同时 User model 将会得到这些函数:

@user.create_action(:like, target: @post)
@user.destroy_action(:like, target: @post)
@user.find_action(:like, target: @post)
@user.like_post(@post)
@user.like_post?(@post)
@user.unlike_post(@post)
@user.block_user(@user1)
@user.unblock_user(@user1)
@user.like_post_ids
@user.block_user_ids
@user.block_by_user_ids

此方法将会替代 Ruby China 里面的类似功能,参见这个 ruby-china/homeland#857

写的这么详细,👍一个

lgn21st 将本帖设为了精华贴。 02月08日 10:35

是不是没写怎么 run migrate

#6 楼 @huacnlee 😀 我试一下,看起来目前只能用 User?Member 或其他的不行?

#7 楼 @huacnlee great,用上了,感觉很方便

马上试下

之前也想写一个,被你占先啦

还准备以后把项目里一个类似的功能提取出来发布呢,结果已被捷足先登了👏

👍勤劳的小蜜蜂

#12 楼 @cao7113 #14 楼 @phun

你们的做法也是这样吗?发出来探讨一下

#16 楼 @huacnlee 类似思路,远没你的丰富翔实,大大的👍,准备采用

#17 楼 @cao7113 😄 以后不要等我,Just do it!

#16 楼 @huacnlee 我是构建一个 Relation 类,主要有 user_id, action_type 和多态 relationable 等数据。辅助 Relationable module,任何想和 User 发生关系(喜欢,赞等等)的东西(比如 Post,Comment)都可以 include relationable,会获得一些糖方法。这样把 relation 当做 resource,相关的操作就可以集中放到 relations_controller 里,route 里也简单了很多。顺便把 relation 的 view 也抽象出了一个 relation_tag helper,在显示的时候可以用 ajax,配合 controller 里的 refresh 方法,这样缓存时就能整个缓存一个统一的 item,等页面加载完再根据当前用户更新其 relation 情况。

等于是做一个包含 MVC 的 relationable 库,功能比较重,多指教!

#19 楼 @phun 哦,那看起来最后大家都是想到一块儿了,中间表结构的思路都是差不多的。 😄

Controller, View Helper 没做,因为实际情况(例如 Ruby China 的场景)可能在 like_xxx 的过程有其他动作(创建通知,Event 之类的),所以就没有像其他 rails-engine 里面的 Gem 那样把整个链路给实现。

其实用 pg 的 LISTEN / UNLISTEN / NOTIFY 语句做订阅说不定更高效

#22 楼 @luikore 呀,可以试试这个东西,用来替代 Ruby China 的某些功能。

这个可以配合 ActionStore 一起用呀!我目前实现这个目的是为了解决数据存储(持久化)的需求,同时保持 MySQL 也可以用。

大大的顶。👍 👍

我是 acts_as_votable 的深度使用者,发现楼主这个,和这个 gem, 有很多类似的地方

#26 楼 @ginchenorlee 😅 我想一把支持更多

不错代码级的复用,对于有多种相同业务的公司,还可以采用服务级复用.做成 serverless 的模式.

https://github.com/chrome/markable 我做的 rails5 兼容版本不小心被我删除了。

30 楼 已删除

这名字,一开始想,ActionStore 这不应该是一个 Rails 负责存储层的一个子框架吗。好吧,其实是存 Action 的 Store

@huacnlee 你好,我想请问下这个,

attr_reader :defined_actions

def find_defined_action(action_type, target_type)
   action_type = action_type.to_s
   name = target_type.to_s.singularize.underscore
   defined_actions.find do |a|
     a[:action_type] == action_type && (a[:action_name] == name || a[:target_type] == target_type)
   end
end

其中defined_actions的值是如何传递进去的?我的理解是attr_reader方法只是生成了一个可以读取对应实例变量的实例方法,和defined_actions这里的直接调用并不对应啊,感谢!

renyijiu 回复
action_store :like, :post, counter_cache: true

调用 action_store 定义 Action 的时候,动态创建的

感觉很好用啊,多谢啦~

有个问题,就比如RubyChina的Topic#show页面这样的场景:当前用户赞了很多个Reply,在显示的时候已经赞过的Reply要标记成红色,这个数据有什么方便的方法能includes进来?感觉一条条reply去判断 current_user.like_reply?(reply) 会有很多条查询,但是 includes 进来所有 like 的数据有又觉得没必要。

唔,看了下 RubyChina 的源码,大概了解了

Controller:

@user_like_reply_ids = current_user&.like_reply_ids_by_replies(@replies) || []

Model:

def like_reply_ids_by_replies(replies)
  return [] if replies.blank?
  return [] if self.like_reply_ids.blank?
  # Intersection between reply ids and user like_reply_ids
  self.like_reply_actions.where(target_id: replies.collect(&:id)).pluck(:target_id)
end

可以解决问题,但感觉有一点不好的是,我现在要做页面同时有“顶”和“踩”…这个步骤我得重复一遍 😂

若想让该用户被删除后,以前留下的赞、关注记录都清空,类似dependent: :destroy的效果,不知道ActionStore能不能实现这功能.. 😃

msl12 回复

我认为这不是必要需求,你可以自行调用 Action model 处理

huacnlee Rails 怎么实现收藏关注功能? 提及了此话题。 04月14日 15:44

怎么查询已关注的对象啊😀

from https://github.com/rails-engine/action-store/commit/6c86875aa1fea686e64cb9f16c15d06a0f122962

多态下存在问题(rails5.1),譬如:

User < Application ;; end
Buyer < User ; ;end
Saler < User ; ;end

Saler.first.create_action :like, target: Buyer.first

Action.where(action_type: :like,  target_type: Buyer.first,  user: Saler.first ) .to_sql  
#  => SELECT * FROM "actions" WHERE "actions"."action_type" = 'like' AND "actions"."target_type" = 'Buyer' AND "actions"."user_type" = 'User' AND "actions"."user_id" = 2 

此处出现问题,user_type 在 sql 查询中为基类 User, 而非预期的子类名 Saler , 导致查询的结果出错,即 like_buyers_count = 0 错误。按如下临时修正后正确:

# lib/action_store/mixin.rb 118-121行更改为
         user_count = Action.where(
            action_type: defined_action[:action_type],
            target_type: action.target_type,
            user_type: action.user_type,
            user_id: action.user_id
          ).count

113 行处或者有更多之处或许也应修改。

或者另一个修改方向:在写入 Action 表时,使用与 rails 一致的写法,即存入基类类名,而非子类类名。 Action.first.update_attributes user: Saler.first

哪个更好呢?:)

问下 确实可以方便的查出 用户喜欢的项目数 但是能反向查出 项目有多少用户喜欢吗

@huacnlee 反向查询 count 时,如果之前没有请求一下 Origin Object,会导致 Target Object xxx_by_users method undefined, 例如:

#User Model
action_store :star, :article, counter_cache: true, user_counter_cache: true

#但是在测试里
@article = create(:article)
@article.star_by_users  #method missing error
User #随便读一下user class
@article.star_by_users # => []
canonpd 展示一下自己的第一个 Rails 作品 - clwy.cn 提及了此话题。 03月22日 18:30
reducm 回复

我现在就遇到这个问题了,怎么解决呢?

判断是不是相互关注的 有啥接口么

huacnlee 回复

赞,是真的挺方便的,项目都有在用。到时候看看要不要升级一下。

看到提供的 api,提个建议,提供更优雅的元编程方法,达到如下效果:

class User < ActiveRecord::Base
  action_store :like, :post
  action_store :block, :user
end

使用:

user = User.first
post = Post.first
user2 = User.last

user.like post
user.like? post # user.liked? post
user.unlike post
user.block user2
user.block? user2 # user.blocked? user2
user.unblock user2
需要 登录 后方可回复, 如果你还没有账号请 注册新账号