活动流我们到处都能够见到,如 twitter 的 timeline,github 上首页的活动流.
它可以很容易让我们获取当前的最新信息.如何在 rails 中实现这个功能呢. 其实已经有人
做了一个 gem - public_activity 它可以很容易得做到跟踪一个 model 对象的更新.下面我们就看看如何使用它来实现这个功能.并且做一些有趣的扩展.
创建应用
我们做一个什么样子的应用呢, 它允许用户分享故事,但是用户需要登录. 当然不登录注册的用户同样能够提交故事.
然后每一个故事都支持like
功能.当前最主要的功能还是活动流.他能够让用户得到最新发生了什么.例子中的活动流包括添加了故事.删除或者喜欢故事这样的事件.
好的,我们开始了.
$ rails new Storyteller -T
这里我们计划使用 rails 的4.2.0版本.但是当中很多功能都是兼容 rails3的
添加我们使用到的 gem:
Gemfile
[...]
gem 'bootstrap-sass', '~> 3.3.1'
gem 'autoprefixer-rails'
gem 'public_activity'
gem 'omniauth-facebook'
[...]
然后 bundle install
当中,bootstrap-sass
和 autoprefixer-rails
是完全可选的.今天我们主要说 public_activity
. 而 omniauth-facebook
是用来用户登录的.
application.scss
@import "bootstrap-sprockets";
@import "bootstrap";
@import 'bootstrap/theme';
我们接下来需要准备好几个对象model, 第一个是 story
, 它包含下面的属性.(这里跳过了默认的 id
, created_at
和 updated_at
属性.)
-
title
(string)故事名称 -
body
(text) 故事内容 -
user_id
(integer) 作为外键,关联用户
第二个会是 User
, 它有下面的属性:
name
-
uid
有 -
avatar_url
字符串, 用户头像 url
创建对象并且迁移:
$ rails g model User name:string uid:string:index avatar_url:string
$ rails g model Story title:string body:text user:references
$ rake db:migrate
然后对下面的 model 做一些修改;
- user.rb
class User < ActiveRecord::Base
has_many :stories
end
- story.rb
clsss Story < Active::Base
belongs_to :user
validates :title, presence: true
validates :body, presence: true
end
设置路由:
resources :stories
delete '/logout', to: 'sessions#destroy', as: :logout
get '/auth/:provider/callback', to: 'sessions#create'
root to: 'stories#index'
其中, /auth/:provider/callback
是一个回调路由,他会被 facebook 作为验证成功之后的回调 url. 这个: provider
意味着你可以提供很多其他的验证方式.
回过头来我们看视图层.
- 首先我们看模板文件:
<div class="navbar navbar-inverse">
<div class="container">
<div class="navbar-header">
<%= link_to 'Storyteller', root_path, class: 'navbar-brand' %>
</div>
<ul class="nav navbar-nav pull-right">
<% if current_user %>
<li><span><%= image_tag current_user.avatar_url, alt: current_user.name %></span></li>
<li><%= link_to 'Log Out', logout_path, method: :delete %></li>
<% else %>
<li><%= link_to 'Log In', '/auth/facebook' %></li>
<% end %>
</ul>
</div>
</div>
<div class="container">
<div class="page-header">
<h1><%= yield :page_header %></h1>
</div>
<% flash.each do |key, value| %>
<div class="alert alert-<%= key %>">
<%= value %>
</div>
<% end %>
<%= yield %>
</div>
没有什么特别的,除了 yield :page_header
还有 page_header
这个帮助方法.
看看这个 application_helper.rb
module ApplicationHelper
def page_header(header)
content_for(:page_header) {header.to_s}
end
end
接下来,我们搞定控制器,把这些都串起来:
stories_controller.rb
class StoriesController < ApplicationController
before_action :find_story, only: [: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] = 'Your story was added!'
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(params[:id])
end
end
有点意思了哦
- views/stories/index.html.erb*
<% page_header "Our cool stories" %>
<p><%= link_to 'Tell one!', new_story_path, class: 'btn btn-primary btn-large' %></p>
<% @stories.each do |story| %>
<div class="well well-lg">
<h2><%= link_to story.title, story_path(story) %></h2>
<p><%= truncate(story.body, length: 350) %></p>
<div class="btn-group">
<%= link_to 'Edit', edit_story_path(story), class: 'btn btn-info' %>
<%= link_to 'Delete', story_path(story), data: {confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-danger' %>
</div>
</div>
<% end %>
views/stories/show.html.erb
<% page_header @story.title %>
<p><%= @story.title %></p>
<div class="btn-group">
<%= link_to 'Edit', edit_story_path(@story), class: 'btn btn-info' %>
<%= link_to 'Delete', story_path(@story), data: {confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-danger' %>
</div>
新建
和编辑
视图就更加简单了:
views/stories/new.html.erb
<% page_header "New cool story" %>
<%= render 'form' %>
views/stories/edit.html.erb
<% page_header "Edit cool story" %>
<%= render 'form' %>
还有表单的模板:
views/stories/_form.html.erb
<%= form_for @story do |f| %>
<%= render 'shared/errors', object: @story %>
<div class="form-group">
<%= f.label :title %>
<%= f.text_field :title, class: 'form-control', required: true %>
</div>
<div class="form-group">
<%= f.label :body %>
<%= f.text_area :body, class: 'form-control', required: true, cols: 3 %>
</div>
<%= f.submit 'Post', class: 'btn btn-primary' %>
<% end %>
还有一个 error
信息的页面:
views/shared/_errors.html.erb
<% if object.errors.any? %>
<div class="panel panel-danger">
<div class="panel-heading">
<h3 class="panel-title">The following errors were found while submitting the form:</h3>
</div>
<div class="panel-body">
<ul>
<% object.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
</div>
<% end %>
追加样式:
.well {
h2 {
margin-top: 0;
}
}
用 facebook 来验证
在 config/initializers/
下面创建一个 omniauth.rb
文件, 内容是:
Rails.application.config.middleware.use OmniAuth::Builder do
provider :facebook, ENV['FACEBOOK_KEY'], ENV['FACEBOOK_SECRET'], scope: 'public_profile'
end
去 facebook 的开发者中心新开一个 app, 然后获取到 app id
和app secret
, NOTE! 一般都是通过环境变量来存贮这些值的.
然后本地开发阶段,就把site url
填为本地的 localhost:3000
. 还有就是权限scope
的问题,目前我们获取用户的基础信息就好.
接下来,我们就新建一个保存用户会话的控制器:
session_controller.rb
class SessionsController < ApplicationController
def create
user = User.from_omniauth(request.env['omniauth.auth'])
session[:user_id] = user.id
flash[:success] = "Welcome, #{user.name}"
redirect_to root_url
end
def destroy
session[:user_id] = nil
flash[:success] = "Goodbye!"
redirect_to root_url
end
end
然后通过 models/user.rb
新建一个方法来保存用户记录:
class << self
def from_omniauth(auth)
user = User.find_or_initialize_by(uid: auth['uid'])
user.name = auth['info']['name']
user.avatar_url = auth['info']['image']
user.save!
user
end
end
这里有一个方法 find_or_initialize_by
用来确保创建或者更新已有用户的信息.
还有一个方法就是 current_user
会返回当前登录用户.
controllers/application_controller.rb
private
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end
helper_method :current_user
`helper_method` 会确保这个方法作为帮助类方法能够在视图中访问到. ok 到这里基本上完全了非本文主题外的所有东西,下面进去最有趣的部分:
### 集成` public_activity`
`public_activity` 的设计思想其实非常简单,就是通过使用`回调`来自动保存故事信息的变化.然后显示这些变化.当然你可能好奇我们是否可以在不接触到数据库表的情况下记录这些动态呢? 我们来看看吧.
- 先来初始化它
> $ rails g public_activity:migration
> $ rails db:migrate
它会创建一个表:` activities`, 让他能够跟踪` story` 对象:
*models/story.rb*
```ruby
include PublicActivity::Model
tracked
这样,只要 对于 story
的增删改查都是会被记录下来的.
显示活动流
你可以单独新起一个页面,或者做成模板来在每个页面上显示.
修改一下控制器,* stories_controller.rb`
before_action :load_activities, only: [:index, :show, :new, :edit]
private
def load_activities
@activities = PublicActivity::Activity.order('created_at DESC').limit(20)
end
你会发现,Activity
对象他是在 PublicActivity
命名空间下面的,这样可以防止命名冲突.我们按照创建时间排序,(最新的在前)然后返回前20个.
我们稍微修改一下页面来让活动流显示在页面的右边.
views/layouts/application.html.erb
<div class="col-sm-9">
<%= yield %>
</div>
<%= render 'shared/activities' %>
views/shared/_activities.html.erb
<div class="col-sm-3">
<ul class="list-group">
<%= @activities.inspect %>
</ul>
</div>
这个地方的样式定义,来自于 bootstrap
, 可以去参考他们的文档.让我们来显示这个流:
views/shared/_activities.html.erb
<div class="col-sm-3">
<%= render_activities @activities %>
</div>
public_activity
约定在 view 下面有一个 public_activity 文件夹. 然后有一个 story文件夹,(或者其他相关的单数形式的对象名的文件夹). 在 story 的文件夹里有这些部分视图: _create.html.erb, _update.html.erb, _destroy.html.erb. 每一个对应的视图都是对应各种的 action的. 他们内部都有一个局部变量 可用. activity
, 别名为 a
.
- 创建这些文件:
views/public_activity/story/_create.html.erb
<li class="list-group-item">
<%= a.trackable.title %> was added.
</li>
views/public_activity/story/_update.html.erb
<li class="list-group-item">
<%= a.trackable.title %> was edited.
</li>
views/public_activity/story/_destroyed.html.erb
<li class="list-group-item">
<%= a.trackable.title %> was deleted.
</li>
这个 trackable
是一个多态关联
,它包含所有的必要信息关于对象的的修改.
这里有一个问题,如果你创建然后删除一个 story, 你会看到一个 error.undefined method 'title' for nil: NilClass
那是因为我们尝试去获取一个已经删除的故事数据. 不过这个很多 fix 的:
- views/public_activity/story/_create.html.erb
<li class="list-group-item">
<% if a.trackable %>
<%= a.trackable.title %> was created.
<% else %>
An article that is currently deleted was added.
<% end %>
</li>
- views/public_activity/story/_update.html.erb
<li class="list-group-item">
<% if a.trackable %>
<%= a.trackable.title %> was edited.
<% else %>
An article that is currently deleted was edited.
<% end %>
</li>
- views/public_activity/story/_destroyed.html.erb
<li class="list-group-item">
An article was deleted.
</li>
非常漂亮,但是好像并没有提供更多的信息呢.何时发生的变化呢?我们是否能够调到那个文章呢,是否能够知道谁修改了他呢?前两个问题好办.
- 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>
- views/public_activity/story/_update.html.erb
<li class="list-group-item">
<span class="glyphicon glyphicon-edit"></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 edited.
<% else %>
An article that is currently deleted was edited.
<% end %>
</li>
- views/public_activity/story/_destroyed.html.erb
<li class="list-group-item">
<span class="glyphicon glyphicon-remove"></span>
<small class="text-muted"><%= a.created_at.strftime('%H:%M:%S %-d %B %Y') %></small><br/>
An article was deleted.
</li>
这里,使用到了 bootstrap 的Glyphicons, 让他们看起来更加好看一些.
存储用户信息
在 activities
表里有一个 owner
字段,他是用来存储执行操作的用户信息的. 问题是, current_user
并不可用在这个对象中.我们有一个 hack 的解决方法
修改一下控制器
- 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
我们移除了 private
属性,但是追加了 hide_action
的修饰,让它不再作为动作来用.
在 models/story.rb
中
tracked owner: Proc.new { |controller, model| controller.current_user ? controller.current_user : nil }
这个 proc
接收2个参数, controller
和 model
. 这个情况下,我们只需要 controller
来调用 current_user
这个方法. model
存储那个被修改的对象.
这样,在 owner
这个子手段里填的就是对应的用户 id 了.
最后我们修改模板视图:
- 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>
- views/public_activity/story/_update.html.erb
<li class="list-group-item">
<span class="glyphicon glyphicon-edit"></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 %>
edited the story <%= link_to a.trackable.title, story_path(a.trackable) %>.
<% else %>
edited the story that is currently deleted.
<% end %>
</li>
- views/public_activity/story/_destroyed.html.erb
<li class="list-group-item">
<span class="glyphicon glyphicon-remove"></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>
deleted a story.
</li>
这下,就可以看到这个具体 story 是由谁操作的.有很多重复的代码,让我们花点时间来重构一下,
使用 i18n fallback 来重构视图
我想把上面的这些部分视图全部 k 掉,只用 shared/_activities.html.erb
,当然,有很多其他的解决方法,
基本的部分视图结构是这样的:
- views/shared/_activities.html.erb
<div class="col-sm-3">
<ul class="list-group">
<% @activities.each do |activity| %>
<li class="list-group-item">
<!-- render activities here -->
</li>
<% end %>
</ul>
</div>
可以发现,上面只有2个地方是不一样的 我们可以改成这样子的:
- 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>
</li>
<% end %>
</ul>
</div>
对应的 css 中可能是这样子的:
-application.scss
.glyphicon-update {
@extend .glyphicon-edit;
}
.glyphicon-create {
@extend .glyphicon-plus;
}
.glyphicon-destroy {
@extend .glyphicon-remove;
}
这里使用了 Sass 中的@ extend
指令来应用新的样式到新的类上.
部分视图通过 i18n 的方式可以是这样子的:
-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>
这里使用了 render_activity
帮助类方法,它的别名 render_activities
我们之前接触过的.
看看翻译文件:
- config/locales/en.yml
en:
activity:
story:
create: 'has told his story'
destroy: 'has removed the story'
update: 'has edited the story'
创建自定义的活动
截至目前,我们还只是在用常规的增删改查,我们怎么跟踪一些自定义的事件呢, 或者能不能捕捉那些不去动这个对象的事件呢?
放心,很容易的. 如果说我们要追加 like
的功能然后记录 like 数
,记录这个事件怎么做到呢?
首先,我们需要添加一个新的列到 stories
表:
$ rails g migration add_likes_to_stories likes:integer
$ rake db:migrate
然后修改路由文件 config/routes.rb
resources :stories do
member do
post :like
end
end
添加 like 按钮到视图:
- 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>
添加控制器动作
- stories_controller.rb
before_action :find_story, only: [: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)
就是数据加一的操作.
@story.create_activity :like
就是添加一个 like
的动态流.
修改翻译文件:
- config/locales/en.yml:
en:
activity:
story:
like: 'has liked the story'
[...]
如果你在使用部分视图来搞定这个的话,就需要创建 views/public_activity/story/_like.html.erb
文件.
这个 create_activity
方法会被一个自定义的 activity给调用,他不需要额外的修改.
还没有完事儿呢,还有一个问题不得不让我再次说道 public_activity 的另外一个特性-- disabling model tracking
你发现了, 其实在@ story.increment!(:likes)
触发了一个高新的操作,它会使得 public_activity
来恢复一个更新事件. 所以@ storty.create_activity :like
会有2条动作信息产生. 这个显然不是我们想要的.只有第一个操作会被跟踪,
public_activity
允许禁止全局跟踪,或者指定不跟踪一个对象. 如果是全局的那就是:
PublicActivity.enables = false
针对某对象的就是:
Story.public_activity_off
看看下面的修改:
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
其实可以更加简单:
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
保存用户自定义信息
试想如果我们想保存其他的信息,怎么办
public_activity
提供了2种方式可以实现这个目标.首先,有一个序列化的 parameters
参数,
@story.create_activity :like, parameters: {why: 'because'}
之后,我们这样取值:
activity.parameters['why']
这个并不方便,但是如果你存储一些额外的信息,还是可以用的.这样在每一个 action中都要调用 create_activity
另外一种,就是通过 custom_fields
如:
创建一个 migration;
- xxx_add_title_to_activities.rb
class AddTitleToActivities < ActiveRecord::Migration
def change
change_table :activities do |t|
t.string :title
end
end
end
然后
$ rake db:migrate
然后修改对象:
- models/story.rb
[...]
tracked owner: Proc.new { |controller, model| controller.current_user ? controller.current_user : nil },
title: Proc.new { |controller, model| model.title }
[...]
提醒一下, model
代表的就是修改的对象. 现在我们可以通过它来显示已经被删除的数据:
-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>
至此,基本上完成了这个东西. 试试吧赶紧!