用 Rails 实现活动流


活动流我们到处都能够见到,如 twitter 的 timeline,github 上首页的活动流.
它可以很容易让我们获取当前的最新信息.如何在 rails 中实现这个功能呢. 其实已经有人
做了一个 gem - public_activity 它可以很容易得做到跟踪一个 model 对象的更新.下面我们就看看如何使用它来实现这个功能.并且做一些有趣的扩展.

###创建应用

我们做一个什么样子的应用呢, 它允许用户分享故事,但是用户需要登录. 当然不登录注册的用户同样能够提交故事.
然后每一个故事都支持`like`功能.当前最主要的功能还是活动流.他能够让用户得到最新发生了什么.例子中的活动流包括添加了故事.删除或者喜欢故事这样的事件.

好的,我们开始了.

> $ rails new Storyteller -T

这里我们计划使用 rails 的4.2.0版本.但是当中很多功能都是兼容 rails3的

添加我们使用到的 gem:

*Gemfile*

```ruby
[...]
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*

```css
@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

```ruby
class User < ActiveRecord::Base
	has_many :stories
end
```

- story.rb

```ruby
clsss Story < Active::Base
	belongs_to :user
	
	validates :title, presence: true
	validates :body, presence: true
end
```

设置路由:

```ruby
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` 意味着你可以提供很多其他的验证方式.

回过头来我们看视图层.

- 首先我们看模板文件:

```html
<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`

```ruby
module ApplicationHelper
  def page_header(header)
    content_for(:page_header) {header.to_s}
  end
end
```

接下来,我们搞定控制器,把这些都串起来:

*stories_controller.rb*

```ruby
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*

```html
<% 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*

```html
<% 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*

```html

<% page_header "New cool story" %>
 
<%= render 'form' %>
```

*views/stories/edit.html.erb*

```html
<% page_header "Edit cool story" %>
 
<%= render 'form' %>
```

还有表单的模板:

*views/stories/_form.html.erb*

```html

<%= 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*

```html
<% 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 %>
```

追加样式:

```css
.well {
  h2 {
    margin-top: 0;
  }
}
```

###用 facebook 来验证

在` config/initializers/`下面创建一个` omniauth.rb` 文件, 内容是:

```ruby
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`

```ruby
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` 新建一个方法来保存用户记录:

```ruby
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`

```ruby
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*

```html
<div class="col-sm-9">
  <%= yield %>
</div>
 
<%= render 'shared/activities' %>
```
*views/shared/_activities.html.erb*

```html
<div class="col-sm-3">
  <ul class="list-group">
    <%= @activities.inspect %>
  </ul>
</div>
```

这个地方的样式定义,来自于` bootstrap`, 可以去参考他们的文档.让我们来显示这个流:

*views/shared/_activities.html.erb*

```html
<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*

```ruby
<li class="list-group-item">
  <%= a.trackable.title %> was added.
</li>
```
*views/public_activity/story/_update.html.erb*

```html
<li class="list-group-item">
  <%= a.trackable.title %> was edited.
</li>
```

*views/public_activity/story/_destroyed.html.erb*

```html
<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

```ruby
<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

```ruby
<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

```ruby

<li class="list-group-item">
  An article was deleted.
</li>
```

非常漂亮,但是好像并没有提供更多的信息呢.何时发生的变化呢?我们是否能够调到那个文章呢,是否能够知道谁修改了他呢?前两个问题好办.

- views/public_activity/story/_create.html.erb

```ruby
<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

```ruby
<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

```html
<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](http://getbootstrap.com/components/#glyphicons), 让他们看起来更加好看一些.

### 存储用户信息

在` activities` 表里有一个` owner` 字段,他是用来存储执行操作的用户信息的. 问题是,` current_user` 并不可用在这个对象中.我们有一个 [hack 的解决方法](https://github.com/pokonski/public_activity#set-the-activitys-owner-to-current_user-by-default)

*修改一下控制器*

- application_controller.rb

```ruby
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`中

```ruby
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


```ruby
<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

```ruby
<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

```ruby
<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

```ruby
<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

```ruby
<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

```css
.glyphicon-update {
  @extend .glyphicon-edit;
}
 
.glyphicon-create {
  @extend .glyphicon-plus;
}
 
.glyphicon-destroy {
  @extend .glyphicon-remove;
}
```

这里使用了 Sass 中的`@ extend` 指令来应用新的样式到新的类上.

部分视图通过 i18n 的方式可以是这样子的:

-views/shared/_activities.html.erb

```html
<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

```xml
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`

```ruby
resources :stories do
  member do
    post :like
  end
end
```

添加 like 按钮到视图:

- views/stories/show.html.erb

```ruby
<% 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

```ruby
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

看看下面的修改:

```ruby
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
```

其实可以更加简单:

```ruby

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

```ruby
class AddTitleToActivities < ActiveRecord::Migration
  def change
    change_table :activities do |t|
      t.string :title
    end
  end
end

```

然后

> $ rake db:migrate

然后修改对象:

- models/story.rb

```ruby
[...]
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

```ruby

<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>
```


至此,基本上完成了这个东西. 试试吧赶紧!