Slapp: 简易聊天墙的Merb教程

有问题或评价,请联系: [email protected]

程序截图: http://www.socialface.com/slapp/screenshot.jpg

简介

欢迎来到Slapp的教程。本文的主要目标是通过构建一个简易的聊天墙应用来介绍一下Merb微框架的主要组件。

本文其次的目标是成为最好的Merb开放教程并能不断更新。同时,我们希望本教程可以逐渐变得丰富来展现Merb框架的所有方面和开发方式。

许可

This tutorial is Copyright 2008 Social Corp. and is licensed under a Creative
Commons Attribution-Noncommercial 3.0 United States License, available at:

相关的源代码以MIT形式的开发源代码许可:

直接浏览代码:

也可以直接下载:

参与人员名单

本教程最初由来自#merb的Slurry撰写。Slurry在撰写本文时主要参照了Merb的官方文档[^a]以及#merb IRC频道的内容。

在学习Merb的RSpec的过程中,还咨询了John Hornbeck的Blerb[^b],参考了Tim Connor的“Isolate controller and view testing in merb”一文[^c]——虽然帮助很大,真正掌握Merb/Rspec还是得益于来自#merb的benburkert。

中文版由ShiningRay 翻译

前期决定

设计上来说,Merb是一个ORM无关的框架。当然,这仅仅是表示有不同的模型层可以选择。对于Merb v0.9.2来说,包括:DataMapper、Sequel和ActiveRecord(来自Rails)。

尽管DataMapper和Sequel都是很好的选择,但现在关注Merb的主流人群基本上都是来自Rails背景;因此,我们在本教程中会继续使用ActiveRecord。

1.) 创建模型

在像Merb这样快速成长的社区中,一个问题可能只需更新或者重装Merb相关的包(gem)就能简单解决,所以让我们先花点时间这样准备一下:

# gem sources -a http://merbivore.com
# gem install merb activerecord merb_activerecord merb_helpers rspec merb_rspec

$ merb-gen app slapp
$ cd slapp

完成了前两行命令后,Merb、ActiveRecord以及Rspec应该已经安装好或已经更新了,同时后两行命令则应该创建了一个初始的空Merb应用。

从这里开始,我们就要告诉Merb:我们要怎样用ActiveRecord,要使用哪个测试框架,以及需要载入哪些确切的merb_helpers包(如表单相关的东西)

编辑:slapp/config/init.rb 并取消第2742 的注释,同时将:dependency "merb_helpers" 添加在
Merb::BootLoader.after_app_loads do 这一行之前:

use_orm :activerecord
...
use_test :rspec
...
dependency "merb_helpers"
...
Merb::BootLoader.after_app_loads do
  ### Add dependencies here that must load after the application loads:

  # dependency "magic_admin" # this gem uses the app's model classes
end

现在我们已经告诉Merb要使用ActiveRecord: use_orm :activerecord , 让我们来生成第一个模型:

$ merb-gen model Post

靠,居然没效。不过等等,你应该还有一个全新的slapp/config/database.yml.sample 文件等待配置,对吧?[^1]

好,现在你要做的就是将这个文件名字改成database.yml 并在其中插入合适的数据库链接信息,像这样:

:development: &defaults
  :adapter: mysql
  :database: slapp_development
  :username: slapp
  :password: SL@rrYin08
  :host: localhost
  :socket: /tmp/mysql.sock
  :encoding: utf8

:test:
  <<: *defaults
  :database: slapp_test

让我们重新运行上面的merb-gen命令,不过这次我们还要再加上一些Post的默认属性:

$ merb-gen model Post body:string created_at:datetime

如果一切正常,那么第一个模型类就有了。让我们使用rake 对数据库进行迁移,来加上表:

$ rake db:migrate

现在,Slapp应该有一个有效可用的Post模型了。为了以防万一,我们将介绍RSpec[^2].

2.) RSpec

RSpec——你可能已经知道了——是Test::Unit的另一种替代测试方案。与RSpec交互主要通过rake任务和spec 命令:

$ rake spec

或者,更加详细的:

$ spec spec/* --format specdoc -c

(重要:这里使用的rake任务来自Merb前沿代码,应该很快会发布为标准包。在那之前,可以使用以下同等任务:
rake specs 以及 rake spec TASK=controllers/pages )

两个任务会运行slapp/spec/* 中的任何代码,简洁起见,我们这里会使用rake。

让我们再次运行测试:

$ rake spec

如你所见,Merb已经为我们创建了一个默认的测试,但是他还不能通过:

Post
- should have specs (PENDING: Not Yet Implemented)
...

打开: slapp/spec/models/post_spec.rb 看看这个测试在哪里:

describe Post do

  it "should have specs"

end

将该spec改成实用的代码,比如:

describe Post do

  it "should be valid when new" do
      post = Post.new
      post.should be_valid
  end

end

该测试看上去好像不多,但是它确实可以验证Merb、RSpec、ActiveRecord和我们的数据库都已经安装成功并工作正常。

$ rake spec

同时,如果我们在此运行新的spec,数据库又出现一个问题。确切地说,我们忘记了创建slapp_test 数据库并将slapp_development 的结构复制过去。

$ rake db:create:all
$ rake db:test:clone

重新运行spec:

$ rake spec
...
Post
- should be valid
...
1 example, 0 failures

这时成功在向我们问候:1 example, 0 failures ,这一行表示所有的spec都通过了。更重要的是,我们遇到并克服了实际使用RSpec的第一个问题。

3.) 控制器

虽然现在从技术上说我们没有控制器也能启动Merb,但是基本上作不了什么。事实上就是什么都作不了,我们还是先来创建一个控制器:

$ merb-gen controller Posts

如你所见,Merb的控制器的命名方式是模型名(或者资源等)的名字复数化,且不使用Controller 后缀。

看一下slapp/app/controllers/posts.rb ,你应该看到我们新的Posts控制器里有一个默认的#index 动作。另外,Merb还应该创建了一个新的spec: slapp/spec/controllers/posts_spec.rb ,里面的内容应该类似于:

describe Posts, "index action" do
  before(:each) do
      dispatch_to(Posts, :index)
  end
end

让我们编辑:slapp/spec/controllers/posts_spec.rb ,更改内容为:

require File.join(File.dirname(__FILE__), "..", 'spec_helper.rb')

describe Posts, "#index" do

  it "should respond correctly" do
      dispatch_to(Posts, :index).should respond_successfully
  end

end

指定控制器是比较直观的,在上面的例子中,我们描述了Posts控制器(从dispatch_to 中返回的)有一个#index 动作并且它能被外部世界成功调用(即,它返回一个HTTP 20x 代码)[^3].

我们想再次运行spec,但是由于我们没有添加任何模型或视图测试,所以让我们执行更加明确的“仅控制器”的rake任务:

$ rake spec:controller

成功的测试结果:

Posts index action
- should respond correctly
...
1 example, 0 failures

漂亮。现在我们已经有了一个可以工作的控制器和一个有效的模型——我们只缺一些好的视图了。

4.) The View from Above

前面当我们创建Posts控制器时,Merb同时也为#index 动作创建了一个草图。位于:slapp/app/views/posts/index.html.erb ,你也许会看看里面有什么,然而,让我们先暂时忽略这个视图,并回到控制器。

slapp/app/controllers/posts.rb 中,我们执行一个简单的 ActiveRecord #find
并在#index 动作中将结果存储为一个实例变量:

class Posts < Application

  def index
      @posts = Post.find(:all, :order => "created_at DESC")
      render
  end

end

然后我们确认该动作仍然是可以调用的:

$ rake spec:controller
...
1 example, 0 failures

就和Rails(或者其他Web框架)中一样,在控制器中创建的实例变量可以在对应的视图中调用。

在本案中,我们可以回到:slapp/app/views/posts/index.html.erb 并将临时文本替换成显示@posts 变量内容的代码。

为了能保持模块化,我们将使用Merb #partial [^4] 功能来达到这个目的。

slapp/app/views/posts/index.html.erb 的内容替换成:

<h1>Welcome to Slapp</h1>
<h2>A simple chat wall</h2>

<p>Recent Posts:</p>
<div id="posts" class="container">
    <%= partial("/shared/post", :with => @posts) %>
</div>

不看HTML,调用#partial 应该还是比较容易理解的——
我们想多次渲染slapp/app/views/shared/_post.html.erb 视图来呈现@posts 的内容。

现在创建: shared/ 目录以及: _post.html.erb 文件:

$ mkdir app/views/shared
$ touch app/views/shared/_post.html.erb

并编辑 slapp/app/views/shared/_post.html.erb 的内容为:

<div id="post-<%= post.id %>" class="post">
    <p class="body"><%= h(post.body) %></p>
    <p class="created"><%= relative_date(post.created_at) %></p>
</div>

上面调用: partial("/shared/post", :with => @posts) 会反复传递一个单个post 对象给视图_post.html.erb 并渲染。

5.) 启动Merb

现在我们有了模型、控制器、以及一个视图,让我们启动Merb:

$ merb
$ curl http://localhost:4000/

你应该看到了一个普通的欢迎页,再转到:

$ curl http://localhost:4000/posts/index

这时你应该看到slapp/app/views/posts/index.html.erb 的内容了。当然,由于我们还未创建任何帖子,所以应该看不到任何东西。

让我们使用交互Merb会话(其实就是一个在Merb应用的内容中启动的IRB)修正上面的问题:

$ merb -i
~ Loaded DEVELOPMENT Environment...
...
>> Post.create(:body => "Memp went down")

使用典型的 ActiveRecord #create 方法,我们现在创建了一个Post。重新载入
Posts#index 页面:

$ curl http://localhost:4000/posts/index

我们应该成功地看到了新的帖子。

6.) 特殊的视图

现在我们已经实现了视图,也许 他们不会经常更改,让我们先描述他们。

首先,创建目录和spec文件:

$ mkdir -p spec/views/posts/
$ touch spec/views/posts/index_spec.rb

然后将一下代码放入slapp/spec/views/posts/index_spec.rb

require File.join(File.dirname(__FILE__), "..", "..", "spec_helper.rb")

describe "posts/index" do

  before(:each) do
      @controller = Posts.new(fake_request)
      @posts = [Post.create(:body => "Merb", :created_at => Time.now), Post.create(:body => "Rocks!", :created_at => Time.now)]
      @controller.instance_variable_set(:@posts, @posts)
      @body = @controller.render(:index)
  end

  it "should have a containing div for the posts" do
      @body.should have_selector("div#posts.container")
  end

  it "should have a div for each individual post" do
      @posts.each do |post|
        @body.should have_selector("div#posts.container div#post-#{ post.id }.post")
      end
  end

  it "should have the contents of each post inside a div with an id and class" do
      @posts.each do |post|
        @body.should match_tag(:div, :id => "post-#{ post.id }", :class => "post", :content => post.body)
      end
  end

  after(:each) do
      Post.destroy_all
  end

end

当使用RSpec描述对象时,常常需要在运行测试的前后维护测试特定的内容。毫不奇怪,RSpec向我们提供了#before#after 块来实现这个目的。

回到代码中:我们在#before 块中首先做的是从我们的 Posts 创建@controller 实例。下面我们使用fake_request 助手[^5]来模拟HTTP请求。

回想一下我们的视图:

...
<p>Recent Posts:</p>
<div id="posts" class="container">
    <%= partial("/shared/post", :with => @posts) %>
</div>

我们知道我们还需要一组帖子来进行测试,碰巧,这就是#before 块的第二和第三行所做的事情:

...
@posts = [Post.create(:body => "Merb", :created_at => Time.now), Post.create(:body => "Rocks!", :created_at => Time.now)]

@controller.instancevariable set(:@posts, @posts)

这里,我们仅仅将插入了一组记录并保存为@controller@posts 实例变量。记住,调用Posts控制器的#index 动作和我们之前所做的没有什么区别,除了我们使用了一个ActiveRecord的#find ,而不是手工使用#new 创建帖子:

前面的Posts控制器Our Posts controller from earlier:

class Posts < Application

  def index
      @posts = Post.find(:all, :order => "created_at DESC")
      render
  end

end

最后,#before 的第四和最后一行渲染了视图并将响应的主体(本案中是HTML)放入了@body 实例变量。

...
@body = @controller.render(:index)
...

(本质上来说,我们是使用fake_request来“查看”Posts#index动作。)

现在,我们的控制器已经设置好了,同时视图也渲染了,我们就开始列出我们对视图中应该有什么的预期。[^6]

首先,我们描述HTML里应该最外面有一个div来放每一个单独的帖子的div:

...
it "should have a containing div for the posts" do
    @body.should have_selector("div#posts.container")
end
...

下面,我们断定容器div确实包含着帖子的div:

...
it "should have a div for each individual post" do
  @posts.each do |post|
      @body.should have_selector("div#posts.container div#post-#{ post.id }.post")
  end
end
...

然后,我们进入每个帖子的div来验证内容准确地匹配对应的Post:

...
it "should have the contents of each post inside a div with an id and class" do
  @posts.each do |post|
      @body.should match_tag(:div, :id => "post-#{ post.id }", :class => "post", :content => post.body)
  end
end
...

最后,我们使用#after 块来删除在#before块中创建的帖子:

...
after(:each) do
  Post.destroy_all
end
...

尽管我们还没真正完成,我们先来验证一下整个应用:

$ rake spec
...
Posts#index
- should respond correctly

Post
- should be valid

posts/index.html.erb
- should have a containing div for the posts
- should have a div with an id and class for each individual post
- should have the contents of each post inside a div with an id and class

Finished in 0.226266 seconds

5 examples, 0 failures

7.) 表单创建

有了浏览帖子的能力之后,现在就可以实现同样重要的创建帖子的功能了。

打开slapp/app/views/posts/index.html.erb 并在帖子列表下面添加该表单[^7]:

...
<p>Post Something:</p>
<% form_tag(:action => url( :controller => "posts", :action => "create") ) do %>
    <%= text_field(:name => "body", :size => 40) %>
    <%= submit_button("Post Message!") %>
<% end %>

几乎不言自明,我们是要构建一个简单的表单,有一个文本输入框和一个提交按钮。

你应该已经注意到我们已经将表单设置为递交到Posts 控制器的#create 动作。我们需要实现这个动作,不过在我们继续之前,我们先快速描述一下这个表单。

编辑: slapp/spec/views/posts/index_spec.rb 并在#after 块上面添加一下内容:

...
it "should have a form to create new posts with a single input and submit button" do
  @body.should match_selector("form[@action=/posts/create]")
  @body.should match_selector("form[@action=/posts/create] input[@name=body]")
  @body.should match_selector("form[@action=/posts/create] button[@type=submit]")
end

和前面一样,我们使用#match_selector 来断言表单、正文输入框以及提交按钮的存在。唯一不同的是我们使用了一个基于HTML属性的选择器[^8]form[@action=/posts/create]

Run the specs:

$ rake spec:view
...
4 examples, 0 failures

我们可以再次启动Merb来亲眼检验一下新的表单了。不过,因为我们已经使用了RSpec,这步不是非常必须的,我们可以立刻继续往下。

说道RSpec,这次,当我们在要去完成#create 动作的时候,我们应该在写代码之前先写出该步骤的spec。

将以下内容复制到 slapp/spec/controllers/posts_spec.rb

require File.join(File.dirname(__FILE__), "..", 'spec_helper.rb')

describe Posts, "#index" do

  it "should respond correctly" do
      dispatch_to(Posts, :index).should respond_successfully
  end

end

describe Posts, "#create" do

  before(:each) do
      @params = { :body => "It was a good game though" }
  end

  it "should redirect to #index after successfully creating a Post" do
      lambda {
        dispatch_to(Posts, :create, @params).should redirect_to("/posts/index")
      }.should change(Post, :count)
  end

end

这里,在#before 块中,我们准备了一个只有一个:body 键的@params 表以便开始描述#create 动作 。下面,我们列出了第一个预期:create动作在它成功创建一个帖子之后应该重定向到#index 动作。

(这就是如果一个浏览器通过表单提交了某些信息,在我们的应用中能看到的情况。)

我们用一个lambda表达式检验一个帖子是不是被创建了,其中RSpec会调用两次:先执行一次,然后等相关的{...} 块执行完之后再执行另一次。

这两次中,RSpec都会调用Post.count ,如果两次调用返回不同的值,那么我们就能确信Post被创建了,那么这个块(该动作)就是成功的。

因为我们还没有编码#create ,所以spec显然会失败:

$ rake spec:controller
...
Posts#create
- should redirect to #index after successfully creating a Post (FAILED - 1)

1)
'Posts#create should redirect to #index after successfully creating a Post' FAILED
count should have changed, but is still 0
...

切换到: slapp/app/controllers/posts.rb 并再次复制以下内容:

class Posts < Application

  def index
      @posts = Post.find(:all, :order => "created_at DESC")
      render
  end

  def create
      Post.create!(:body => params[:body])
      redirect url(:action => "index")
  end

end

现在我们定义了#create ,现在回到spec文件,应该就可以通过了:

$ rake spec:controller
...
2 examples, 0 failures

通过了这些spec,我们就有了一个可以正常工作的聊天墙了。

听起来是个好消息,我们也几乎就要完成了。我们还需要做得就是检验创建一个新的帖子需要一定的文本,否则则会出现一个异常。

8.) 收尾

现在,任何都可以点击 “Post Message!” 然后创建一个新的帖子。因为我们并不想让一堆空白的帖子占据聊天墙,所以我们应该在创建新帖子之前校验至少有一些文本被提交了。

因为本文是一个教程,我们不打算将所有东西都仔细进行合适的处理,所以这里我们直接使用ActiveRecord的validates_length_of 过滤器。 ;-)

打开: slapp/spec/models/post_spec.rb 并观察我们现有的spec:

...
it "should be valid when new" do
  post = Post.new
  post.should be_valid
end

因为我们要校验正文文本的存在,这个spe已经不再有效,我们需要如下的内容来替代:

describe Post do

  it "should NOT be valid when new" do
      post = Post.new
      post.should_not be_valid
  end

  it "should require at least two body characters to be valid" do
      post = Post.new
      post.should_not be_valid
      post.errors.on(:body).should include("is too short (minimum is 2 characters)")
  end

end

和平时一样,我们首先运行失败的spec来建立我们对于特定行为的预期:

...
Post
- should NOT be valid when new (FAILED - 1)
- should require at least two body characters to be valid (FAILED - 2)

1)
'Post should NOT be valid when new' FAILED
expected valid? to return false, got true
./spec/models/post_spec.rb:7:

2)
'Post should require at least two body characters to be valid' FAILED
expected valid? to return false, got true
./spec/models/post_spec.rb:12:
...

然后实现上面提出的修正,在本案中,则是在slapp/app/models/post.rb 中加入一样:

class Post < ActiveRecord::Base

  validates_length_of :body, :minimum => 2

end

再次运行spec来检验我们的修正是否有效:

$ rake spec:model
...
2 examples, 0 failures

这时我们的模型完成了。让我们继续给Posts控制器添加一个spec——
我们需要描写当提交一个帖子没有包含正文的时候,我们没有真正去处理这个错误而已直接返回由我们的ORM抛出的异常。

编辑slapp/spec/controllers/posts_spec.rb 并加入下面这个spec:

...
it "should raise an exception when insufficient body text is submitted" do
  lambda {
      dispatch_to(Posts, :create).should redirect_to("/posts/index")
  }.should raise_error(ActiveRecord::RecordInvalid)
end

如你所见,我们仅仅是不带@params 表来调用#create 。这创建一个没有正文的空Post,这样就会导致ActiveRecord的校验失败,并抛出我们预期的RecordInvalid 异常。

有了这个,我们的第一个聊天墙的版本就完成了。就和前面一样,你可以通过启动Merb并浏览Posts#index 动作来试试程序:

$ merb
$ curl http://localhost:4000/posts/index

最后的思考

从这里开始,你可能还有很多东西想添加,例如:分页、动态发布/更新、SPAM过滤器、文本格式化等等。

这些对于任何优秀的聊天程序都是很基本的,你都可以利用Merb来实现。

同时,别忘了浏览官方的项目首页看看有没有最新的更新、看看别人的版本甚至创建属于你自己的:

有任何问题/评价,请致电[email protected] 或者在#merb找 Slurry

脚注

[^1]: The observant may have also just found their first “Merb” bug.. the
generator claimed to have made a “database.sample.yml” file, although the file
is really named “database.yml.sample”. :-)

[^2]: RSpec links in order of approximate handyness to the beginner:

[^3]: Merb RSpec controller matchers:

[^4]: Merb partials:

[^5]: Merb fake_request helper:

[^6]: Merb RSpec view matchers:

[^7]: Merb Form Helpers:

[^8]: Hpricot CSS Selectors:

你可能感兴趣的:(浏览器,datamapper,ActiveRecord,Rails,rspec)