在这一期的 精通 Grails 中,Scott Davis 展示如何将文件上传到 Grails 应用程序,并设置一个 Atom syndication feed。完成最后这些部分之后,Blogito 便成为一个完整的博客服务器。
在过去几期的 精通 Grails 文章中,您一直在逐步构建一个小型的博客服务(Blogito)。在这篇文章中,Blogito 将最终完工,成为一个实用的博客应用程序。您将为博客条目主体实现文件上传功能,并添加自己制作的用于聚合的 Atom feed。
|
但是,在开始之前,请注意在上一篇文章(“身份验证和授权”)中,我加入的认证使 UI 中出现一个细小的 bug。在加入新的特性之前,应该修复这个 bug。
修复隐藏的 bug
启动 Grails 时,grails-app/conf/Bootstrap.groovy 增加 2 个用户和 4 个新的博客条目。但是,如果尝试通过 Web 界面增加博客条目,会发生什么?可以使用下面的步骤试试看:
- 以用户名 jsmith 和密码 wordpass 登录。
- 单击 New Entry。
- 添加标题和摘要。
- 单击 Create。
您将看到以下错误:Property [author] of class [class Entry] cannot be null。那么,这个 bug 是如何引入到应用程序中的?毕竟,bootstrap 代码还能正常工作。
在第一篇 Blogito 文章(“改变 Grails 应用程序的外观”)中,我让您通过输入 grails generate-views Entry 生成 Groovy Server Pages(GSP)视图。在随后的文章中,我更改了 domain 类,但是从未让您再回过头来生成视图。当我添加 Entry 与 User 之间的 1:M 关系时,磁盘上的 create.gsp 视图一直不变,如清单 1 所示。(还记得吗,belongsTo 创建一个名为 author 的字段,该字段的类型为 User)。
清单 1. 打破 GSP 的 1:M 关系
class Entry { static belongsTo = [author:User] String title String summary Date dateCreated Date lastUpdated } |
不得不说,要使一切同步,最安全的方式还是通过动态脚手架生成视图 — 特别是在开发的早期,域模型不断变化的时候,更是如此。当然,不能仅仅依靠通过脚手架生成的视图,但是,当您在磁盘上生成 GSP 时,使它们保持最新的责任就从 Grails 转移到您自己身上。
如果现在为 Entry 类生成视图的话,Grails 会提供一个组合框,其中显示一个 Author 列表,如清单 2 所示。您自己不要 这样做 — 这只是为了演示。稍后我将提供两种不同的选项。
清单 2. 为 1:M 关系生成的组合框
<g:form action="save" method="post" > <div class="dialog"> <table> <tbody> <!-- SNIP --> <tr class="prop"> <td valign="top" class="name"> <label for="author">Author:</label> </td> <td valign="top" class="value ${hasErrors(bean:entryInstance, field:'author','errors')}"> <g:select optionKey="id" from="${User.list()}" name="author.id" value="${entryInstance?.author?.id}" ></g:select> </td> </tr> <!-- SNIP --> </tbody> </table> </div> </g:form> |
注意 <g:select> 元素。字段名为 author.id。在 “GORM - 有趣的名称,严肃的技术” 中可以了解到,列表中显示的文本来自 User.toString() 方法。该文本通常也是表单提交时作为字段值发回到服务器的值。在这里,optionKey 属性覆盖字段值,从而发回 Author 的 id。(要了解更多关于 <g:select> 标记的信息,请参阅 参考资料)。
为 EntryController.groovy 提供 author.id 字段的最快方式是将一个隐藏字段添加到表单中,如清单 3 所示。由于执行 create 动作前必须登录,而登录的 User 是博客条目的 author,因此对于这个值可以安全地使用 session.user.id。
清单 3. 从表单传递 author.id 字段
<g:form action="save" method="post" > <input type="hidden" name="author.id" value="${session.user.id}" /> <!-- SNIP --> </g:form> |
对于像 Blogito 这样的简单的应用程序,这样也许就足够了。但是,这样做留下了一个漏洞,使客户端的黑客有机会为 author.id 注入不同的值。为确保彻底的安全,可以在 save 闭包中添加 Entry.author,如清单 4 所示:
清单 4. 将 author.id 保存在服务器上
def save = { def entryInstance = new Entry(params) entryInstance.author = User.get(session.user.id) if(!entryInstance.hasErrors() && entryInstance.save()) { flash.message = "Entry ${entryInstance.id} created" redirect(action:show,id:entryInstance.id) } else { render(view:'create',model:[entryInstance:entryInstance]) } } |
这是生成控制器时得到的标准 save 闭包,再加上一行定制的代码。entryInstance.author 行根据 session.user.id 值从数据库获取 User,并填充 Entry.author 字段。
在下一节中,您将定制 save 闭包,以处理文件上传,所以您仍可能在安全性方面犯错误,将 清单 4 中的代码添加到 EntryController.groovy 中。重新启动 Grails,确保可以通过 HTML 表单成功地添加新的 Entry。
文件上传
现在又可以创建 Entry,接下来该添加另一个特性。我希望用户在创建新的 Entry 时可以上传文件。这种文件可以是包含整个博客条目的 HTML,也可以是图像或任何其他文件。为实现该特性,需要涉及到 Entry domain 类、EntryController 和 GSP 视图 — 并且要增加一个新的 TagLib。
首先,看看 grails-app/views/entry/create.gsp。添加一个新字段,用于上传文件,如清单 5 所示:
清单 5. 添加一个用于文件上传的字段
<g:uploadForm action="save" method="post" > <!-- SNIP --> <tr class="prop"> <td valign="top" class="name"> <label for="payload">File:</label> </td> <td valign="top"> <input type="file" id="payload" name="payload"/> </td> </tr> </g:uploadForm> |
注意,<g:form> 标记已经被改为 <g:uploadForm>。这样便支持从 HTML 表单上传文件。实际上,也可以保留 <g:form> 标记,并增加一个 enctype="multipart/form-data" 属性。(用于 HTML 表单的默认 enctype 是 application/x-www-form-urlencoded)。
如果正确设置了表单的 enctype(或者使用 <g:uploadForm>),就可以添加 <input type="file" /> 字段。这样便为用户提供了一个按钮,用于浏览本地文件系统,并选择上传的文件,如图 1 所示。我的例子使用 Grails 徽标;您也可以使用任何自己喜欢的图像。
图 1. 包含文件上传字段的 Create Entry 表单
现在,客户端表单已经做好了,接下来可以调整服务器端代码,以便用上传的文件做有用的事情。在文本编辑器中打开 grails-app/controllers/EntryController.groovy,将清单 6 中的代码添加到 save 闭包中:
清单 6. 显示关于上传的文件的信息
def save = { def entryInstance = new Entry(params) entryInstance.author = User.get(session.user.id) //handle uploaded file def uploadedFile = request.getFile('payload') if(!uploadedFile.empty){ println "Class: ${uploadedFile.class}" println "Name: ${uploadedFile.name}" println "OriginalFileName: ${uploadedFile.originalFilename}" println "Size: ${uploadedFile.size}" println "ContentType: ${uploadedFile.contentType}" } if(!entryInstance.hasErrors() && entryInstance.save()) { flash.message = "Entry ${entryInstance.id} created" redirect(action:show,id:entryInstance.id) } else { render(view:'create',model:[entryInstance:entryInstance]) } } |