+2

Tìm hiểu về public_activity gem với Rails

Vấn đề

Chức năng cần ghi lại các hành động của người dùng trong trang web thì nó luôn hay có. VÍ dụ như trang Github, nó sẽ hiển thị các activities của mình như 1 người bạn của mình đã tạo hoặc clone 1 reporstory hay 1 issue vừa được mở hay repo của mình đã được forked hay gắn sao,...

Hay Facebook cũng hiển thị các bài post, replies mới nhất của mình và bạn bè

Đây là 1 cách dễ dàng để người dùng có thể biết được nội dung thay đổi gần đây. Vậy làm thế nào để chúng ta có thể ghi lại các lịch sử này.

Với Rails, nó thật sự dễ dàng với gem pulic_activity

Triển khai

Chuẩn bị sample_app

Bây giờ, giả sử chúng ta đang cần tạo ra 1 ứng dụng cho phép người dùng chia sẻ các stories. Người dùng có thể đăng nhập và story. guests thì có thể xem cũng như like cho mỗi story. Và phần mình sẽ phải focus là activity feed - tức là người dùng có thể xem được những gì đã xảy ra gần đâu. Trong example này, feed activity sẽ focus vào các story đã được thêm, xóa hoặc like.

rails new sample-app-stories

Trong sample-app này mình sẽ dùng rails 6 và ruby 2.7

Bây giờ, mình cần 2 table là User và Story, và tiếp tục mình cần config model như sau:

# User model
class User < ActiveRecord::Base
  has_many :stories
end

# Story model
class Story < ActiveRecord::Base
  belongs_to :user

  validates :title, presence: true
  validates :body, presence: true
end

Tại controller Stories chúng ta sẽ định nghĩa các actions cho nó

class StoriesController < ApplicationController
  before_action :find_story, only: %i(destroy show edit update)

  def index
    @stories = Story.order created_at: :DESC
  end

  def new
    @story = Story.new
  end

  def create
    @story = Story.new story_params
    if @story.save
      flash[:success] = "Story add success"
      redirect_to root_path
    else
      render :new
    end
  end

  def edit; end

  def update
    if @story.update_attributes story_params
      flash[:success] = "The story was edited!"
      redirect_to root_path
    else
      render :edit
    end
  end

  def destroy
    if @story.destroy
      flash[:success] = "The story was deleted!"
    else
      flash[:error] = "Cannot delete this story"
    end
    redirect_to root_path
  end

  def show; end

  private

  def story_params
    params.require(:story).permit :title, :body
  end

  def find_story
    @story = Story.find_by id:params[:id]
    return if @story
    
    flash[:warning] = "Story not found"
    redirect_to root_path
  end
end

Tích hợp gem public_activity

Thực hiện bổ sung gem trong Gemfile:

gem "public_activity"

Ý tưởng ở đây là mình sẽ sử dụng callback để có thể tự động lưu trữ thông tin về những thay đổi trong các table đã được chỉ định mà cụ thể ở đây là story table. Thông tin này sẽ được sử dụng để hiển thị tất cả các hoạt động gần đây.

Bắt đầu thôi:

  • Thực hiện run
rails g public_activity:migration
rails db:migrate

Với câu lệnh trên, trong DB của chúng ta sẽ tự động tạo 1 table là activities.

  • Thực hiện áp dụng public_activity cho Story model
# models/Story.rb
include PublicActivity::Model
tracked

Với code trên, bây giờ, bất cứ khi nào bạn thực hiện các hành động như save, update_attributes, destroy hay các action khác, public_activity sẽ được kích hoạt để ghi lại sự kiện này vào table activities

Hiển thị Activity Feed

Để hiển thị các events này, mình sẽ chủ động tạo riêng 1 page riêng biệt thay vì dùng chung của các view khác

Tại StoriesController,

# controllers/StoriesController
before_action :load_activities, only: %i(destroy show edit update)

[...]

private

[...]

# Lấy 20 hoạt động được tạo mới nhất
def load_activities
  @activities = PublicActivity::Activity.order('created_at DESC').limit(20)
end

Tại view chỉ cần khai báo partial như sau:

# views/shared/_activities.html.erb
<div class="col-sm-3">
  <%= render_activities @activities %>
</div>
# views/public_activity/story/_create.html.erb
<li class="list-group-item">
  <span class="glyphicon glyphicon-plus"></span>
  <small class="text-muted"><%= a.created_at.strftime('%H:%M:%S %-d %B %Y') %></small><br/>
  <% if a.trackable %>
    <%= link_to a.trackable.title, story_path(a.trackable) %> was added.
  <% else %>
    An article that is currently deleted was added.
  <% end %>
</li>

trackable là polymorphic association, nó có tất cả thông tin cần thiết về mô hình đã được sửa đổi

Ngoài partial create, chúng ta có thể tạo tương tự cho các file _update, _destroy

Lưu trữ thông tin người dùng (owner)

Có một trường đặc biệt được gọi là chủ sở hữu trong bảng activities nhằm lưu trữ thông tin về người dùng chịu trách nhiệm cho hành động. Tuy nhiên, vấn đề là phương thức current_user không có sẵn bên trong mô hình, nên chúng ta cần custom lại code của application controller như sau:

# application_controller.rb
[...]
include PublicActivity::StoreController

def current_user
  @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end

helper_method :current_user
hide_action :current_user
[...]

Thêm hide_action: current_user đảm bảo rằng method này không được coi là một action của controller.

  • Tại model Story thì thêm code
# models/story.rb
[...]
tracked owner: Proc.new { |controller, model| controller.current_user ? controller.current_user : nil }
[...]

Procedure nhận hai đối số: controller và model. Trong trường hợp này, chúng ta chỉ cần controller để gọi phương thức current_user; mô hình lưu trữ đối tượng đã được sửa đổi

Với điều này tại chỗ, tại login, add/change một số stories và kiểm tra bảng activities. Field owner phải được điền bằng id của người dùng.

  • Chỉnh sửa view partial
# views/public_activity/story/_create.html.erb
<li class="list-group-item">
  <span class="glyphicon glyphicon-plus"></span>
  <small class="text-muted"><%= a.created_at.strftime('%H:%M:%S %-d %B %Y') %></small><br/>
  <strong><%= activity.owner ? activity.owner.name : 'Guest' %></strong>
  <% if a.trackable %>
    added the story <%= link_to a.trackable.title, story_path(a.trackable) %>.
  <% else %>
    added the story that is currently deleted.
  <% end %>
</li>

Bây giờ, đối với các hành động mới được thực hiện, bạn sẽ thấy tên của mình được hiển thị; đối với những cái trước phải có text “Guest” thay thế.

Mọi thứ đều hoạt động tốt, nhưng các phần của chúng tôi chứa rất nhiều sự trùng lặp - trên thực tế, mã gần như giống hệt nhau giữa _create.html.erb, _update.html.erb và _destroy.html.erb. Vì vậy, hãy dành một chút thời gian để cấu trúc lại nó.

Sử dụng I18n để refactor Activity Partials

Mình sẽ thực hiện loại bỏ hoàn toàn các phần hoạt động đó và chỉ làm việc vớishared/_activities.html.erb file. Tuy nhiên, có thêm 1 số solution khác bạn có thể tham khảo

Code mơí sau khi tái cấu trúc sẽ là

# views/shared/_activities.html.erb
<div class="col-sm-3">
  <ul class="list-group">
    <% @activities.each do |activity| %>
      <li class="list-group-item">
        <span class="glyphicon glyphicon-<%= activity.key.match(/\.(.*)/)[1] %>"></span>
        <strong><%= activity.owner ? activity.owner.name : 'Guest' %></strong>
        <%= render_activity activity, display: :i18n %>
        <% if activity.trackable %>
          "<%= link_to activity.trackable.title, story_path(activity.trackable) %>"
        <% else %>
          with unknown title.
        <% end %>
      </li>
    <% end %>
  </ul>
</div>

Các trường key chứa chuỗi ở dạng . Vì vậy, ví dụ trong trường hợp của chúng ta, nó có thể là story.create, story.update hoặc story.destroy.

Chúng tôi đang sử dụng chỉ thị @extend của Sass để áp dụng các kiểu cho các lớp mới của chúng tôi.

Vấn đề với text có thể được giải quyết với sự trợ giúp của fallbacks. Như chúng ta đã thấy, theo mặc định, public_activity sẽ tìm kiếm các phần tử bên trong thư mục public_activity /. Tuy nhiên, nếu chúng tôi cung cấp tùy chọn display:: i18n, các bản dịch I18n sẽ được sử dụng thay thế. Như vậy chúng ta còn có thể quốc tế hóa project (multi language) trong tương lai 😄

activity:
  model_name:
    create: '...'
    destroy: '...'
    update: '...'
    other_action: '...'
en:
  activity:
    story:
      create: 'has told his story'
      destroy: 'has removed the story'
      update: 'has edited the story'

Kết quả mà chúng ta muốn đây

Custom activities

Với nội dung trên chúng ta chỉ làm việc với các hoạt động CRUD cơ bản và các hoạt động được lưu tự động. Nhưng nếu chúng ta muốn theo dõi một số sự kiện tùy chỉnh thì sao? Hoặc nếu có nhu cầu kích hoạt một hoạt động mà không cần chạm vào mô hình?

Đừng lo lắng, điều này có thể được thực hiện khá dễ dàng. Giả sử chúng ta muốn thêm nút "like" và đếm lượt thích cho mỗi bài đăng. Hơn nữa, một hoạt động đặc biệt cũng nên được ghi lại.

  • Đầu tiên, chúng ta cần add new column vào stories table
rails g migration add_likes_to_stories likes:integer
rails db:migrate
resources :stories do
  member do
    post :like
  end
end
  • Add button like vào view
# views/stories/show.html.erb
<% page_header @story.title %>

<p>
  <span class="label label-default"><%= pluralize @story.likes, "like" %></span>
  <%= link_to content_tag :span, "",
       class: "glyphicon glyphicon-thumbs-up") + "like it",
       like_story_path(@story), class: "btn btn-default btn-sm", method: :post %>
</p>
  • Và tại controller
# stories_controller.rb

before_action :find_story, only: %i(destroy show edit update like)

[...]

def like
  @story.increment! :likes
  @story.create_activity :like
  flash[:success] = "Thanks for sharing your opinion!"
  redirect_to story_path @story
end
[...]

@ story.increment! (: likes) chỉ cần thêm 1 vào số lượt thích và lưu kết quả vào cơ sở dữ liệu. @ story.create_activity: như thực sự tạo ra một hoạt động mới bằng cách cung cấp khóa tương tự (chúng ta đã nói về khóa trước đây khi cấu trúc lại các thành phần)

  • Tiếp tục sẽ là modify file translations
# config/locales/en.yml:
en:
  activity:
    story:
      like: "has liked the story"

(*) Tuy nhiên:

Có một vấn đề sẽ cho phép hiển thị thêm một tính năng của public_activity – disabling model tracking. Bạn thấy đấy, @story.increment!(:likes) code kích hoạt bản cập nhật khiến public_activity ghi lại update event. Vì vậy, @story.create_activity: like sẽ dẫn đến việc ghi lại hai hoạt động cho một hành động.

Đây rõ ràng không phải là điều chúng ta mong muốn. Hoạt động đầu tiên nên được thực hiện mà không có bất kỳ theo dõi nào cả.

public_activity cho phép tắt tính năng theo tracking globally hoặc cho một mô hình cụ thể.

# disable tracking globally
PublicActivity.enabled = false

# disable tracking on model’s level use
Story.public_activity_off

Nhưng rõ ràng, việc bật tắt tính năng tracking, nó vẫn chưa thể giải quyết được vấn đề của chúng ta.

Chúng ta còn 1 cách nữa là chỉnh sửa trong Controller.

# stories_controller.rb

def like
  Story.public_activity_off
  @story.increment! :likes
  Story.public_activity_on
  @story.create_activity :like
  flash[:success] = "Thanks for sharing your opinion!"
  redirect_to story_path @story
end

Hoặc đơn giản hơn chỉ cần:

# stories_controller.rb

def like
  without_tracking do
    @story.increment!(:likes)
  end
  @story.create_activity :like
  flash[:success] = 'Thanks for sharing your opinion!'
  redirect_to story_path(@story)
end

private

def without_tracking
  Story.public_activity_off
  yield if block_given?
  Story.public_activity_on
end

Bây giờ, hoạt động increment sẽ không làm cho hoạt động update được kích hoạt.

Lưu thông tin tùy chỉnh

Giả sử, bây giờ chúng ta muốn lưu trữ một số thông tin bổ sung về hoạt động. public_activity trình bày hai cách để đạt được kết quả mong muốn. Trước hết, có trường parameters được tuần tự hóa có trong bảng activities mà chúng ta có thể truy cập ngay lập tức:

@story.create_activity :like, parameters: {why: "because"}

Sau đó, chúng tôi có thể truy cập thông tin này

activity.parameters[:why]

Tuy nhiên, điều này không phải lúc nào cũng thuận tiện. Nếu bạn cần lưu một số dữ liệu cho mỗi hoạt động, thì phương pháp này yêu cầu gọi hàm create_activity cho mỗi hành động.

Đối với các trường hợp khác, có các custom fields có sẵn.

Ví dụ: hãy lưu trữ tiêu đề của câu chuyện để chúng tôi có thể tìm nạp ngay cả khi câu chuyện sau đó bị xóa.

  • Tạo file migrate để add title field vào table Activities
# xxx_add_title_to_activities.rb

class AddTitleToActivities < ActiveRecord::Migration
  def change
    change_table :activities do |t|
      t.string :title
    end
  end
end
  • Tiếp theo là chỉnh sửa model
# models/story.rb

[...]
tracked owner: Proc.new { |controller, model| controller.current_user ? controller.current_user : nil },
        title: Proc.new { |controller, model| model.title }
[...]
  • Cuối cùng là phần hiển thị
# shared/_activities.html.erb

<div class="col-sm-3">
  <ul class="list-group">
    <% @activities.each do |activity| %>
      <li class="list-group-item">
        <span class="glyphicon glyphicon-<%= activity.key.match(/\.(.*)/)[1] %>"></span>
        <small class="text-muted"><%= activity.created_at.strftime('%H:%M:%S %-d %B %Y') %></small><br/>
        <strong><%= activity.owner ? activity.owner.name : 'Guest' %></strong>
        <%= render_activity(activity, display: :i18n) %>
        <% if activity.trackable %>
          "<%= link_to activity.trackable.title, story_path(activity.trackable) %>"
        <% elsif activity.title %>
          <span class="text-muted">"<%= activity.title %>"</span>
        <% else %>
          with unknown title.
        <% end %>
      </li>
    <% end %>
  </ul>
</div>

Nội dung bài viết mình tham khảo tại: https://www.sitepoint.com/activity-feeds-rails/


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí