有问题或评价,请联系: [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
并取消第27
和42
的注释,同时将: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:
- http://rspec.info/documentation/expectations.html
- http://rspec.info/documentation/test_unit.html
- http://rspec.info/documentation/before_and_after.html
- http://rspec.info/rdoc/classes/Spec/Matchers.html
- http://rspec.info/rdoc/index.html
[^3]: Merb RSpec controller matchers:
[^4]: Merb partials:
[^5]: Merb fake_request helper:
[^6]: Merb RSpec view matchers:
[^7]: Merb Form Helpers:
- http://merbivore.com/documentation/merb-plugins/head/merb_helpers/index.html?a=M000046&name=form_tag
[^8]: Hpricot CSS Selectors: