2.3 应对新需求
我们已经为firstApp应用完成了用户注册和登录功能,当然这个功能实在太过简单。假设用户提出了新需求:希望用户登录该系统后,能够添加留言,浏览所有留言,查看具体的一条留言和删除留言。也就是说,他希望将这个应用扩展成一个小型的留言系统。
这时候就是Rails敏捷开发大展身手的时候了:当应用的需求发生变更时,它可以非常容易地在原有应用的基础上进行扩充,增加新的功能,这也是Rails被称为敏捷开发框架的原因。
现在,我们已经确定了需要将firstApp应用改变为一个留言系统。那么,先修改该应用的标题:在/views/layouts路径下的user.rhtml视图文件中,将title中的文本由原来的“第一个Web应用”标题修改为“欢迎光临留言系统”,并将id="header-section"的div标签中的文本由原来的“第一个Web应用”修改为“留言系统”。此外,为了使得留言的视图文件和用户的视图文件能共用一个相同的装饰页面,把user.rhtml视图文件重命名为application.rhtml。
2.3.1 添加Model
将本Web应用扩充为一个留言系统,首先需要添加一个留言Model,这个Model对应数据库中的留言表。下面,在数据库中创建一个新的数据表,该表的MySQL脚本如下。
-- 创建留言表:messages
create table messages (
id int not null auto_increment,
-- 定义留言标题:title
title varchar(50) not null,
-- 定义留言内容:detail
detail varchar(255) not null,
-- 定义留言图片的数据:picture_data
picture_data blob,
-- 定义留言图片的类型:picture_content_type
picture_content_type varchar(255),
-- 定义留言时间:created_at
created_at timestamp not null,
-- 定义留言用户的ID:user_id
user_id int not null,
-- 建立外键约束:user_id为messages表参照users表的外键,它参照了users表中的id列
constraint foreign key (user_id) references users(id),
-- 定义主键
primary key (id)
);
我们将数据库中的留言表定义成messages,该表中定义了一个picture字段,用来存放用户贴图在系统中的路径;而定义的user_id字段则是messages表参照users表的外键。此外,根据Rails的表对应类的约定,该表将对应Model中的Message类。
接着,用scaffold代码生成器为Message Model生成基本的CRUD操作,即在DOS窗口进入Rails应用所在的路径下,执行如下命令:
ruby script\generate scaffold Message Message
执行完毕上面的命令之后,Rails会在该应用中为我们自动生成Message Model的支架。
在浏览器的地址栏输入http://localhost:3000/message或http://127.0.0.1:3000/message,将会看到可以列出所有留言信息的list页面。由于我们尚未添加任何留言,因此该页面中没有任何留言记录。单击页面下方的New message链接,将会进入添加留言的页面,如图2.9所示。

图2.9 添加新留言
读者会注意到,图2.9中的Picture data域是一片空白,而Picture content type域是一个单行文本框,不是一个文件域。这是因为,我们的messages表中picture字段是定义成varchar类型的。所以,Rails就会将它当成一个文本字段来处理,在模板中将它设计成一个单行文本框。这只是视图模板,不用担心,因为在后面完善这个firstApp应用时,我们还要修改这里。
另外,由于messages表中定义了不允许为空的user_id字段,而添加留言的页面中没有留言人的输入框(也没有必要有该输入框),同时,在scaffold为我们生成的message_controller.rb控制器文件中,用于添加一条留言记录的create Action并没有为user_id属性赋值,所以,这时候是不能成功地添加一条留言记录的。由此可看出,尽管scaffold代码生成器的功能十分强大,但仅仅使用它也并不是万能的,这是肯定的!它只是生成一个具有基本的CRUD操作的支架,不可能完成整个应用的所有功能。这就需要程序员在scaffold生成的代码的基础上进行修改和完善,这也就是我们下面几节将要介绍的内容。
2.3.2 改进“浏览留言”
我们希望当用户成功登录系统后,首先进入浏览留言页面,这需要修改user_controller.rb控制器文件中的login方法,将合法用户登录成功后重定向到message控制器中的list Action。代码片段如下:
redirect_to :controller=>"message", :action=>"list"
message控制器的list Action负责列出系统中所有留言,scaffold默认生成的list Action已经具有分页功能,因此我们不需要改变list Action。list Action的代码如下:
def list
# 执行分页查询
@message_pages, @messages = paginate :messages, :per_page => 10
end
上面的list Action里使用了Rails提供的分页查询支持,通过这种分页查询支持,可以非常方便地以分页方式列出所有留言。
与该list Action对应的视图文件是/app/views/message路径下的list.rhtml文件。其中,用于列出所有留言内容的代码片段如下:
<table width=100% border=0 align="center" cellpadding="0" cellspacing="0" bgcolor="#BFCAE6">
<tr>
<td colspan="6" id="title">所有留言</td>
</tr>
<tr>
<td width="30%"><b>标题</b></td>
<td width="30%"><b>时间</b></td>
<td width="10%"><b>是否有图</b></td>
<td width="15%"><b>留言人</b></td>
<td colspan="2"> </td>
</tr>
<!-- 遍历@messages实例变量中的每一个message对象 -->
<% for message in @messages %>
<!-- 调用cycle帮助方法来设置循环的行的CSS样式 -->
<tr class="<%= cycle("even", "odd") %>" >
<!-- 调用truncate帮助方法,将title值设置为只显示前25个字符 -->
<td width="30%" ><%= truncate(message.title, 25) %></td>
<!-- 调用自定义的show_date帮助方法,来返回指定格式的日期形式 -->
<td width="30%" ><%= show_date(message.created_at) %></td>
<td width="10%">
<!-- Message对象调用自定义的has_picture?方法,查询留言是否有贴图 -->
<% if message.has_picture? then %>
<font style="color:red">有图</font>
<% end %>
</td>
<!-- Message对象调用user实例方法,返回该对象对应的User对象 -->
<td width="15%"><%= message.user.name %></td>
<td width="7%"><%= link_to '查看', :action => 'show', :id => message %></td>
<td>
<% if session[:user_id] == message.user.id then %>
<%= link_to '删除', { :action => 'destroy', :id => message }, :confirm => '您确定删除吗?',
:post => true %>
<% else %>
<% end %>
</td>
</tr>
<% end %>
</table>
修改后的list页面比原来的代码丰富了很多,主要是增加了设置奇数行和偶数行的CSS样式,只显示标题的前25个字符,格式化日期的显示方式,以及为有贴图的留言显示“有图”字样。
值得注意的是,在上面的代码中,Message对象调用user实例方法,来返回该对象对应的User对象。读者可能觉得奇怪,哪里定义的user实例方法呢?因为Message Model和User Model是两个关联实体,且Message Model是User Model对应的从表实体,所以,在message.rb模型文件中加入如下代码:
# 调用Rails中的belongs_to方法,声明Message对象是User对象的从表对象
belongs_to :user
即可由belongs_to提供一系列方便地操作关联对象的实例方法,其中就有user实例方法。这样,当Message对象调用user实例方法时,就能十分方便地查找到它所对应的User对象,而不需要通过如下代码来返回User对象。
user_id=message.user_id
user=User.find(user_id)
这种方式是先查询出留言对象的user_id,再通过这个user_id查找到对应的User对象。相比之下,我们在Rails中仅仅调用一个belongs_to方法,即可实现同样的功能,代码要简洁得多。
由于list.rhtml文件中调用了自定义的has_picture?方法,来查询留言是否有贴图,所以需要在message.rb模型文件中定义该方法。代码如下:
# 设置允许上传的图片文件最大为50 kb
MAX_IMAGE_SIZE = 50*1024
# 该方法用于检查Message对象是否有图片
def has_picture?
transaction do
# 如果Message对象的图片内容的类型不为nil
if self.picture_content_type!=nil then
# 如果存在合法的上传图片
# 即:文件类型为图片,且文件大小不超过允许上传的最大图片的大小
if (self.picture_content_type).split("/")[0]=="image" &&
self.picture_data.size<= MAX_IMAGE_SIZE
then
return true
end
end
return false
end
end
该方法的定义中,先判断Message对象的picture_content_type属性值是否为nil;如果不为nil,再判断是否存在合法的上传图片。也就是,仅当Message对象的picture_content_type属性值为图片类型的值,且文件大小不超过允许上传的最大图片的大小时,has_picture?方法才返回true,其他情况均返回false。
另外,由于list.rhtml文件中调用了自定义的show_date帮助方法,来返回格式化的日期形式,因而,需要在/app/helpers路径下的message_helper.rb文件中定义该方法。代码片段如下:
def show_date(date)
# 日期将会被格式化成:年月日 时分秒
date.year.to_s+"年"+date.month.to_s+"月"+date.day.to_s+"日"+" "+date.to_s.split(" ")[3]
end
该方法用于格式化需要显示的日期,使之符合我们的计时习惯。
控制器中不需要修改scaffold生成的代码。当用户使用这个应用成功登录后,就会进入浏览留言的页面。
2.3.3 改进“新增留言”
我们希望当新增留言的时候,允许用户上传图片,并能判断上传文件的类型是否为图片和文件是否过大。这在Rails中不难做到,不需要使用任何插件,只用十分简洁的代码即可实现图片的上传功能!这的确是一件让程序员愉快的事情。
在这一节,我们来改进“新增留言”,着重介绍图片上传的实现。
修改/app/views/message路径下的new.rhtml视图文件。修改后的代码如下:
<table valign="top" width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<center>
<%= error_messages_for 'message' %>
</center>
<!-- 将multipart选项设置为true,允许上传文件 -->
<%= start_form_tag ({ :action => 'create', :id => @message }, :multipart => true) %>
<table align="center" width="400" border="0" cellpadding="0" cellspacing="0"
bgcolor="#BFCAE6">
<tr>
<td colspan="2" id="title">新增留言</td>
</tr>
<tr>
<td width="15%"><b>标题</b></td>
<td ><%= text_field('message', 'title', :size => "30")%></td>
</tr>
<tr>
<td width="15%"><b>内容</b></td>
<td ><%= text_area 'message', 'detail', "cols" => 40, "rows" => 10 %></td>
</tr>
<tr>
<td width="15%"><b>贴图</b></td>
<!-- 调用file_field帮助方法,生成一个文件域 -->
<td ><%= file_field 'message', 'picture' %></td>
</tr>
<tr>
<td colspan="2" align="center"><%= submit_tag '提交' %></td>
</tr>
</table>
<%= end_form_tag %>
</td>
</tr>
</table>
上面的代码在start_form_tag帮助方法中指定multipart的选项值为true,使得该表单能够发送文件数据。并且,通过调用file_field帮助方法,生成了一个文件域。这里的picture属性只是个虚拟属性,因为在数据库的messages表中并不存在这个属性。这需要我们对这个虚拟属性做一些处理,使之对应messages表中的真实属性。具体实现方式下面会有详细介绍。
上面的表单是没有为留言时间设计表单域的,因为我们可以通过更简便的方式来为Message对象的这个属性赋值:messages表中表示留言时间的字段名为created_at,数据类型设计为timestamp,这样,当保存一条Message对象对应的记录时,Rails就会自动将当前时间赋值给created_at列,而不需要我们手动赋值。
在message_controller.rb控制器文件中,需要修改create方法,修改后该方法的代码片段如下。
def create
# 查找出当前留言的User对象
user=User.find(session[:user_id])
# 将该User对象赋值给参数中Message对象的user属性
params[:message][:user]=user
# 构造一个Message对象,并使用message参数来初始化该对象
@message = Message.new(params[:message])
# 如果Message对象能成功保存进数据库
if @message.save
flash[:notice] = '新增留言成功!'
# 重定向到list Action
redirect_to :action => 'list'
else
# 提交new Action
render :action => 'new'
end
end
在create方法的定义中,前面两句代码是scaffold生成的默认代码中所没有的。这是因为user_id是messages表参照users表的外键列,scaffold不会自动生成对外键列的操作。所以,我们需要根据session[:user_id]来查找出留言的User对象,并把该对象赋值给表单参数中的Message对象,作为它的一个user属性。这样,当Message对象调用save方法保存进数据库的时候,会让该Messge对象对应的数据行参照到该User实例对应的数据行。
在Message Model文件中,重定义一个picture=方法。因为我们的new.rhtml视图文件中有一个picture表单域,当提交表单后控制器将发送一个message[:picture]的请求参数,这个请求参数将要求Message类里包含一个picture=方法,该方法用于接受message[:picture]请求参数。
picture=方法负责把message[:picture]请求参数(这个请求参数值是一个文件对象,里面包含了非常丰富的信息)解析出来。下面是picture=方法的代码:
# 提供picture=方法,将一个picture的表单域设置成Message对象的多个属性
def picture=(picture_field)
transaction do
# 如果用户上传了图片
if picture_field.size>0 then
# @picture_size为上传图片的文件大小
@picture_size=picture_field.size
# @picture_type为上传图片的文件类型
@picture_type=picture_field.content_type.chomp
# 设置Message对象的picture_content_type属性
self.picture_content_type =@picture_type
# 设置Message对象的picture_data属性
self.picture_data = picture_field.read
end
end
end
提供了picture=方法之后,我们就将message[:picture]请求参数与messages表中的真实属性picture_content_type和picture_data对应起来了。
在上传文件方面,Rails处理得很好,表单中的文件域参数值不再是一个简单的类型值,而是已经被包装成一个文件对象,它也有size,content_type和read方法,直接调用这些方法,即可返回这个文件对象的大小、文件类型和包含的二进制数据。
当用户添加一条留言时,我们需要对留言对象进行模型校验,这可以通过在message.rb模型文件中重写validate方法来实现。代码如下:
def validate
# 验证title不能为空
errors.add("", "标题不能为空") if title.empty?
# 验证detail不能为空
errors.add("", "内容不能为空") if detail.empty?
# 下面校验上传的图片
if @picture_type != nil
# 校验上传图片的文件类型
errors.add("", "贴图不是合法的图片文件") unless @picture_type =~ /^image/
end
if @picture_size != nil
# 校验上传图片的文件大小
errors.add("", "贴图文件太大,应不能超过50 KB") if @picture_size > MAX_IMAGE_SIZE
end
end
在上面的代码中,我们校验留言标题、留言内容的值不能为空,上传的文件类型必须是图片形式,并且文件大小不能超过允许上传的最大图片值(MAX_IMAGE_SIZE为允许上传的最大图片值,这个常量在前面的 2.3.2节中已经有定义)。
登录留言系统后,在list页面中单击下方的“新增留言”链接,进入新增留言页面。然后在该页面中输入数据,选择一个图片文件上传。如果图片不合法,将会在浏览器中看到如图2.10所示的效果。

图2.10 新增留言时图片不合法
由图2.10可看到,页面中显示出了错误提示信息,系统仍停留在新增留言的页面。选择一个合法的图片文件,再单击“提交”按钮,才能成功地添加留言。
2.3.4 改进“查看留言”
scaffold代码生成器生成的代码是不能满足我们查看留言的需求的,它不能显示图片,界面也十分简陋,这需要我们来改进“查看留言”部分的代码。
修改/app/views/message路径下的show.rhtml视图文件,修改后用于显示留言的代码片段如下。
<table width="80%" border=0 align="center" class="pt9" cellpadding=10 cellspacing="1" bgcolor="#BFCAE6">
<tr>
<td>
<p>
<label for="message_name">标题:</label>
<%= @message.title %>
</p>
<p>
<label for="message_description">内容:</label>
<%= @message.detail %>
</p>
<p>
<label for="message_picture">图片:</label>
<!-- 如果留言有图片 -->
<% if @message.has_picture? then %>
<!-- 调用url_for帮助方法,生成一个URL -->
<img height=120 width=120 align="center" src="<%= url_for(:action =>
"show_picture", :id => @message.id) %>"/>
<% else %>
<i>无图片</i>
<% end %>
</p>
<p>
<label for="message_time">留言时间:</label>
<%= show_date(@message.created_at)%>
</p>
<p>
<label for="message_category">留言人:</label>
<!-- 调用user实例方法,返回Message对象对应的User对象 -->
<%= @message.user.name %>
</p>
<br>
<%= link_to '返回', :action => 'list' %>
</td>
</tr>
</table>
上面的代码中,Message对象调用has_picture?方法来查询留言是否有图片(has_picture?方法定义的代码已经在前面2.3.2节中介绍过)。如果留言有图片,则调用Rails提供的url_for帮助方法,生成一个将会向show_picture Action发送请求的URL。然后通过这个Action来负责显示图片。
在message_controller.rb控制器文件中,添加show_picture Action的定义,该Action负责把数据库里保存的图片二进制数据转换成图片输出。该Action代码片段如下:
# 该方法用于在线显示图片
def show_picture
@message = Message.find(params[:id])
# 如果留言有图片
if @message.has_picture? then
# 通过调用send_data方法来发送图片中的二进制数据给用户
send_data(@message.picture_data,
# 通过将disposition选项设置为inline,指定直接显示该图片,而不是附件
:disposition => "inline",
# 指定一个HTTP内容类型
:type => @message.picture_content_type)
end
end
上面的方法定义通过调用Rails的send_data方法来把二进制数据转换成图片输出。其中,send_data方法的disposition选项应该指定为inline,否则,将会把文件以附件的形式下载。
修改完毕上述的代码之后,可以在浏览留言页面中选择一条标注为红色的“有图”字样的记录,单击它的“查看”链接,即可查看该留言的详细信息,包括图片。在浏览器中的显示效果如图2.11所示。

图2.11 查看有图的留言
如图2.11所示,页面中不仅显示出留言的文本信息,同时显示出了图片。如果用户查看的是一条没有图片的留言记录,则用于显示图片的位置将仅仅显示“无图片”的文本提示信息。
2.3.5 改进“删除留言”
对于删除留言,我们应当考虑到,只有留言的发表者才有权利看到“删除”链接,并能够删除留言。这需要我们在scaffold代码生成器生成的雏形上做一些改进。
在/app/views/message路径下的list.rhtml视图文件中,添加代码来控制“删除”链接的显示。代码片段如下:
<% if session[:user_id] == message.user.id then %>
<%= link_to '删除', { :action => 'destroy', :id => message }, :confirm => '您确定删除吗?', :post => true %>
<% else %>
<% end %>
经过上面的代码控制之后,只有当留言发布者的ID与session[:user_id]相等时,才显示“删除”链接,否则只是输出空格。因此,对于不是留言发布者的用户,这个“删除”链接将是不可见的。
修改message_controller.rb控制器文件中的destroy方法定义。修改后的代码片段如下:
def destroy
# 查找指定ID的Message对象
message=Message.find(params[:id])
# 如果session[:user_id]与该Message对象对应的User对象的ID值相等
if session[:user_id] == message.user.id then
# 删除该Message对象
message.destroy
redirect_to :action => 'list'
end
end
上面的代码中,我们先调用find方法查找出当前需要删除的Message对象,如果该对象对应的User对象的ID值与session[:user_id]的值相等,即当前用户是该条留言的发布者,则删除Message对象对应的记录,并重定向到list Action。
修改完毕上述的代码之后,我们就完成了“删除留言”部分的改进工作。
到此为止,这个firstApp应用已经完善和扩充完毕了。现在可以把scaffold生成的我们不需要的代码或文件删掉。






