内容


精通 Grails

文件上传和 Atom 联合

在 Grails 中获取和存储数据

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: 精通 Grails

敬请期待该系列的后续内容。

此内容是该系列的一部分:精通 Grails

敬请期待该系列的后续内容。

在过去几期的 精通 Grails 文章中,您一直在逐步构建一个小型的博客服务(Blogito)。在这篇文章中,Blogito 将最终完工,成为一个实用的博客应用程序。您将为博客条目主体实现文件上传功能,并添加自己制作的用于聚合的 Atom feed。

但是,在开始之前,请注意在上一篇文章(“身份验证和授权”)中,我加入的认证使 UI 中出现一个细小的 bug。在加入新的特性之前,应该修复这个 bug。

修复隐藏的 bug

启动 Grails 时,grails-app/conf/Bootstrap.groovy 增加 2 个用户和 4 个新的博客条目。但是,如果尝试通过 Web 界面增加博客条目,会发生什么?可以使用下面的步骤试试看:

  1. 以用户名 jsmith 和密码 wordpass 登录。
  2. 单击 New Entry
  3. 添加标题和摘要。
  4. 单击 Create

您将看到以下错误:Property [author] of class [class Entry] cannot be null。那么,这个 bug 是如何引入到应用程序中的?毕竟,bootstrap 代码还能正常工作。

在第一篇 Blogito 文章(“改变 Grails 应用程序的外观”)中,我让您通过输入 grails generate-views Entry 生成 Groovy Server Pages(GSP)视图。在随后的文章中,我更改了 domain 类,但是从未让您再回过头来生成视图。当我添加 EntryUser 之间的 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 属性覆盖字段值,从而发回 Authorid。(要了解更多关于 <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 表单的默认 enctypeapplication/x-www-form-urlencoded)。

如果正确设置了表单的 enctype(或者使用 <g:uploadForm>),就可以添加 <input type="file" /> 字段。这样便为用户提供了一个按钮,用于浏览本地文件系统,并选择上传的文件,如图 1 所示。我的例子使用 Grails 徽标;您也可以使用任何自己喜欢的图像。

图 1. 包含文件上传字段的 Create Entry 表单
包含文件上传字段的 Create Entry 表单
包含文件上传字段的 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])
    }
}

注意,这里使用 request.getFile() 方法获得上传的文件的引用。有了该引用后,便可以对它进行各种类型的内省。清单 7 显示上传 Grails 徽标后的控制台输出:

清单 7. 上传文件后的控制台输出
Class: class org.springframework.web.multipart.commons.CommonsMultipartFile
Name: payload
OriginalFileName: Grails_logo.jpg
Size: 8065
ContentType: image/jpeg

如果知道 Grails 在幕后使用 Spring MVC 框架,那么对此不应感到奇怪:上传的文件是作为 CommonsMultipartFile 对象提供给控制器的。除了公布 HTML 表单字段的名称外,这个类还允许访问原始文件名、文件大小(单位为字节)和文件的 MIME 类型。

接下来的步骤是将上传的文件保存到某个地方。在 save 闭包中添加几行代码,如清单 8 所示:

清单 8. 将上传的文件保存到磁盘
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}"

      def webRootDir = servletContext.getRealPath("/")
      def userDir = new File(webRootDir, "/payload/${session.user.login}")
      userDir.mkdirs()
      uploadedFile.transferTo( new File( userDir, uploadedFile.originalFilename))
    }

    if(!entryInstance.hasErrors() && entryInstance.save()) {
        flash.message = "Entry ${entryInstance.id} created"
        redirect(action:show,id:entryInstance.id)
    }
    else {
        render(view:'create',model:[entryInstance:entryInstance])
    }
}

在 Web root 下创建 payload/jsmith 目录后,就可以使用 uploadedFile.transferTo() 方法将文件保存到磁盘。File.mkdirs() 方法是无损的,所以可以多次调用该方法,而不必担心当目录已经存在时会丢失已有的文件。

接下来,将一个 String 字段添加到 Entry 类,以存储 filename,如清单 9 所示。注意要添加一个约束,使这个新字段同时为 blank(在 HTML 表单中)和 nullable(在数据库中)。

清单 9. 将 filename 字段添加到 Entry
class Entry {
  static constraints = {
    title()
    summary(maxSize:1000)
    filename(blank:true, nullable:true)
    dateCreated()
    lastUpdated()
  }

  static mapping = {
    sort "lastUpdated":"desc"
  }

  static belongsTo = [author:User]

  String title
  String summary
  String filename
  Date dateCreated
  Date lastUpdated
}

最后,将 filename 添加到 save 闭包中的 Entry 对象中。清单 10 显示完整的 save 闭包:

清单 10. 将 filename 存储在 Entry 中
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}"

      def webRootDir = servletContext.getRealPath("/")
      def userDir = new File(webRootDir, "/payload/${session.user.login}")
      userDir.mkdirs()
      uploadedFile.transferTo( new File( userDir, uploadedFile.originalFilename))
      entryInstance.filename = uploadedFile.originalFilename
    }

    if(!entryInstance.hasErrors() && entryInstance.save()) {
        flash.message = "Entry ${entryInstance.id} created"
        redirect(action:show,id:entryInstance.id)
    }
    else {
        render(view:'create',model:[entryInstance:entryInstance])
    }
}

将上传的文件保存到文件系统的另一种方法是将它们直接存储在数据库中。如果在 Entry 中创建一个名为 payloadbyte[] 字段,那么可以完全绕过前面添加到 save 闭包的所有定制代码。但是,如果那样做的话,您将错过下一节中所有的趣事。

显示上传的文件

如果不将上传的文件在某个地方显示出来,那有何意义呢?打开 grails-app/views/entry/_entry.gsp,添加清单 11 中的代码:

清单 11. 用于显示上传的图像的 GSP 代码
<div class="entry">
  <span class="entry-date">
      <g:longDate>${entryInstance.lastUpdated}</g:longDate> : ${entryInstance.author}
  </span>
  <h2><g:link action="show" id="${entryInstance.id}">${entryInstance.title}</g:link></h2>
  <p>${entryInstance.summary}</p>

  <g:if test="${entryInstance.filename}">
    <p>
      <img src="${createLinkTo(dir:'payload/'+entryInstance.author.login,
                               file:''+entryInstance.filename)}"
           alt="${entryInstance.filename}"
           title="${entryInstance.filename}" />
    </p>
  </g:if>
</div>

由于上传文件是可选的,我将输出包含在一个 <g:if> 块中。如果 entryInstance.filename 字段被填充,则在一个 <img> 标记中显示结果。

图 2 显示新的列表,同时还在显眼的地方显示上传的 Grails 徽标:

图 2. 显示上传的图像
显示上传的图像
显示上传的图像

但是,如果用户上传其他东西,而不是图像呢?这时就不是将更多的逻辑放入到 GSP 中了,最好的地方是一个定制的 TagLib。

创建 TagLib

Blogito 在 grails-app/taglib 中已经有两个 TagLib:DateTagLib.groovy 和 LoginTagLib.groovy。在一个 TagLib 中可以定义任意数量的定制标记,但是这一次我建议创建一个新的 TagLib,以便按语义将标记分组。在命令提示符下输入 grails create-tag-lib Entry,并添加清单 12 中的代码:

清单 12. 创建 displayFile 标记
class EntryTagLib {

  def displayFile = {attrs, body->
    def user = attrs["user"]
    def filename = attrs["filename"]

    if(filename){
      def extension = filename.split("\\.")[-1]
      def userDir = "payload/${user}"

      switch(extension.toUpperCase()){
        case ["JPG", "PNG", "GIF"]:
             def html = """
             <p>
               <img src="${createLinkTo(dir:''+userDir,
                                        file:''+filename)}"
                    alt="${filename}"
                    title="${filename}" />
             </p>
             """

             out << html
             break

        case "HTML":
             out << "p>html</p>"
             break
        default:
             out << "<p>file</p>"
             break
      }
    }else{
      out << "<!-- no file -->"
    }
  }

}

不久后可以看到,该代码创建一个 <g:displayFile> 标记,该标记需要两个属性:userfilename。如果 filename 属性被填充,则取得文件扩展名,并将其转换为大写形式。

Groovy 中的 Switch 语句比 Java 中的对等物的灵活性要大得多。首先,可以在 String 上进行切换(而 Java 语言只能在 int 上进行切换)。更令人惊奇的是,case 既可以指定一个条件列表 List,也可以指定单个的条件。

有了这个 TagLib 后,可以大大简化 _entry.gsp 局部模板,如清单 13 所示:

清单 13. 简化的局部模板
<div class="entry">
  <span class="entry-date">
      <g:longDate>${entryInstance.lastUpdated}</g:longDate> : ${entryInstance.author}
  </span>
  <h2><g:link action="show" id="${entryInstance.id}">${entryInstance.title}</g:link></h2>
  <p>${entryInstance.summary}</p>

  <g:displayFile filename="${entryInstance.filename}"
                 user="${entryInstance.author.login}" />

</div>

重新启动 Grails,并再次上传 Grails 徽标。在添加对其他文件类型的支持之前,应该确保 TagLib 重构没有破坏已有的功能。

现在,可以确信仍可以上传图像。接下来就是添加对其他文件类型的支持,这只需在 switch 块中实现适当的 case。清单 14 演示如何处理上传的 HTML 文件,以及为默认的 case 创建一个链接来下载该文件:

清单 14. 完整的 switch/case
class EntryTagLib {

  def displayFile = {attrs, body->
    def user = attrs["user"]
    def filename = attrs["filename"]

    if(filename){
      def extension = filename.split("\\.")[-1]
      def userDir = "payload/${user}"

      switch(extension.toUpperCase()){
        case ["JPG", "PNG", "GIF"]:
             //SNIP
             break

        case "HTML":
             def webRootDir = servletContext.getRealPath("/")
             out << new File(webRootDir+"/"+userDir, filename).text
             break
        default:
             def html = """
             <p>
               <a href="${createLinkTo(dir:''+userDir,
                                       file:''+filename)}">${filename}</a>
             </p>
             """
             out << html
             break
      }
    }else{
      out << "<!-- no file -->"
    }
  }

}

创建两个新的文本文件,以便测试这个新的行为:一个名为 test.html,另一个名为 noextension。将清单 15 中的内容添加到适当的文件中,上传该文件,确认 TagLib 是否按预期显示每个文件:

清单 15. 用于上传的两个示例文件
//test.html
<p>
This is some <b>test</b> HTML.
</p>

<p>
Here is a link to the <a href="http://grails.org">Grails</a> homepage.
</p>

<p>
And here is a link to the 
<img src="//grails.org/images/grails-logo.png">Grails Logo</img>.
</p>



//noextension
This file doesn't have an extension.

Web 浏览器看上去应该如图 3 所示:

图 3. 显示所有 3 种类型的上传的文件
显示所有 3 种类型的上传的文件
显示所有 3 种类型的上传的文件

添加 Atom feed

至此,您应该明白一种截然不同的模式构成。对于添加到 Grails 应用程序的每个新特性,很可能都要涉及模型、视图和控制器。您还可以额外添加局部模板或 TagLib。

将 Atom feed 添加到 Blogito 同样也遵从该模式。虽然不要求更改模型,但最终要做所有其他的事情。您将:

  1. Entry 控制器中添加一个闭包,以处理 Atom 请求。
  2. 创建一个新的 GSP 页面,用于以格式良好的 Atom 文档的形式呈现结果。
  3. 创建一个新的局部模板和一个新的定制标记,以加快进程。

本来您可以安装一个很好的 Feeds 插件,该插件可以为 Grails 应用程序添加 RSS 和 Atom 功能(请参阅 参考资料),但是我认为您将发现,Atom 格式非常简单,您自己完全可以应付。为证明这一点,您可以查看已有的 Atom feed 的源代码,或者查看关于 Atom 的 Wikipedia 页面最后的例子(请参阅 参考资料)。您甚至可以阅读 RFC 4287,这是用于 Atom 格式的 IETF 规范(请参阅 参考资料)。或者,您可以继续阅读本文,看看一个特定于 Grails 的解决方案。

首先,在 EntryController.groovy 中添加一个 atom 闭包,如清单 16 所示:

清单 16. 在 EntryController.groovy 中添加一个 atom 闭包
def atom = {
  if(!params.max) params.max = 10
  def list = Entry.list( params )
  def lastUpdated = list[0].lastUpdated
  [ entryInstanceList:list, lastUpdated:lastUpdated ]
}

这个闭包与标准的 list 闭包之间惟一的不同是增加了 lastUpdated 字段。由于该列表已经按 lastUpdated 排序(这要归因于 Entry domain 类的 static mapping 块中的 sort "lastUpdated":"desc" 设置),只需从该列表的第一个 Entry 中获取该字段,就可以得到最近的日期。

接下来,创建 grails-app/views/entry/atom.gsp。添加清单 17 中的代码:

清单 17. atom.gsp
<% response.setContentType("application/atom+xml") 
%><feed xmlns="http://www.w3.org/2005/Atom">
  <title type="text">News from Blogito.org</title>
  <link rel="alternate" type="text/html" href="http://blogito.org/"/>
  <link rel="self" type="application/atom+xml" href="http://blogito.org/entry/atom" />
  <updated><g:atomDate>${lastUpdated}</g:atomDate></updated>
  <author><name>Blogito.org</name></author>
  <id>tag:blogito.org,2009-01-01:entry/atom</id>
  <generator uri="http://blogito.org" version="0.1">Hand-rolled Grails code</generator>

  <g:each in="${entryInstanceList}" status="i" var="entryInstance">
<g:render template="atomEntry" bean="${entryInstance}" var="entryInstance" />
  </g:each>

</feed>

可以看到,以上代码做的第一件事是将 MIME 类型设置为 application/atom+xml。然后,提供关于该 feed 的一些基本的元数据:updatedauthorgenerator 等。

如果想避免在整个 feed 中硬编码 blogito.org,那么可以让 atom 闭包获取 request.serverName,将它赋给一个变量,并在响应 hashmap 中返回它,同时返回的还有 entryInstanceListlastUpdated。为了完全动态化,可以使用 request.scheme 返回 http,并使用 request.serverPort 返回 80。(唯一要避免使用 request.serverName 变量的地方是在 id 中,稍后我将讨论到这一点)。

对于 Atom feed 来说,以多种不同的格式提供链接并不少见。从 type 属性可以看出,该 feed 提供两个链接:一个是 HTML 链接,另一个是 Atom 格式的指向它本身的链接。self 链接特别有用;如果有一个不是自己下载的 Atom 文档,那么通过该链接就可以回溯到规范来源。

id 字段是 Atom feed 的惟一标识符,它不同于 URI 或可下载该 Atom feed 的当前位置。(您刚才已经知道,<link> 元素提供 feed 的当前来源)。在这个例子中,我使用 Mark Pilgrim 提供的技术生成一个惟一的、永久的 ID 字符串:将域名、feed 初次进入服务的日期和 URI 剩下的部分组合到一起。(要了解更多信息,请参阅 参考资料)。

id 的各个部分远不如整个字符串的惟一性重要。应确保这个 id 以后不会因为无意中传入来自控制器的变量而变化 — 对于 feed id,它应该既是惟一的,又是不变的。即使服务器的 address 发生变化,如果 feed 的内容不变,那么 feed id 也应该保持不变。

更新后的字段应该符合特定的格式 — 2003-12-13T18:30:02Z,或者确切地说是 RFC 3339。(要了解详细信息,请参阅 参考资料)。在已有的 grails-app/taglib/DateTagLib.groovy 文件中添加一个 atomDate 闭包,如清单 18 所示:

清单 18. 添加 atomDate 标记
import java.text.SimpleDateFormat

class DateTagLib {
  public static final String INCOMING_DATE_FORMAT = "yyyy-MM-dd hh:mm:ss"
  public static final String ATOM_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'-07:00'"

  def atomDate = {attrs, body ->
    def b = attrs.body ?: body()
    def d = new SimpleDateFormat(INCOMING_DATE_FORMAT).parse(b)
    out << new SimpleDateFormat(ATOM_DATE_FORMAT).format(d)
  }

  //SNIP
}

为了完成 Atom feed,创建 grails-app/views/entry/_atomEntry.gsp,并添加清单 19 中的代码:

清单 19. _atomEntry.gsp 局部模板
<entry xmlns='http://www.w3.org/2005/Atom'>
  <author>
    <name>${entryInstance.author.name}</name>
  </author>
  <published><g:atomDate>${entryInstance.dateCreated}</g:atomDate></published>
  <updated><g:atomDate>${entryInstance.lastUpdated}</g:atomDate></updated>
  <link href="http://blogito.org/blog/${entryInstance.author.login}/
    ${entryInstance.title.encodeAsUnderscore()}" rel="alternate" 
    title="${entryInstance.title}" type="text/html" />
  <id>tag:blogito.org,2009:/blog/${entryInstance.author.login}/
    ${entryInstance.title.encodeAsUnderscore()}</id>
  <title type="text">${entryInstance.title}</title>
  <content type="xhtml">
    <div xmlns="http://www.w3.org/1999/xhtml">
      ${entryInstance.summary}
    </div>
  </content>
</entry>

最后需要做的是向未经认证的用户开放 Atom feed。调整 EntryController.groovy 中的 beforeInterceptor,如清单 20 所示:

清单 20. 向未经认证的用户开放 Atom feed
class EntryController {

  def beforeInterceptor = [action:this.&auth, except:["index", "list", "show", "atom"]]

  //SNIP
}

重新启动 Grails,当访问 http://localhost:9090/blogito/entry/atom 时,应该产生一个格式良好的 Atom feed,如清单 21 所示:

清单 21. 格式良好的 Atom feed
<feed xmlns="http://www.w3.org/2005/Atom">
  <title type="text">News from Blogito.org</title>
  <link rel="alternate" type="text/html" href="http://blogito.org/"/>
  <link rel="self" type="application/atom+xml" href="http://blogito.org/entry/atom" />
  <updated>2009-04-20T00:03:34-07:00</updated>
  <author><name>Blogito.org</name></author>
  <id>tag:blogito.org,2009-01-01:entry/atom</id>
  <generator uri="http://blogito.org" version="0.1">Hand-rolled Grails code</generator>

<entry xmlns='http://www.w3.org/2005/Atom'>
  <author>
    <name>Jane Smith</name>
  </author>
  <published>2009-04-20T00:03:34-07:00</published>
  <updated>2009-04-20T00:03:34-07:00</updated>
  <link href="http://blogito.org/blog/jsmith/Testing_with_Groovy" rel="alternate" 
    title="Testing with Groovy" type="text/html" />
  <id>tag:blogito.org,2009:/blog/jsmith/Testing_with_Groovy</id>
  <title type="text">Testing with Groovy</title>
  <content type="xhtml">
    <div xmlns="http://www.w3.org/1999/xhtml">
      See Practically Groovy
    </div>
  </content>

<!-- SNIP -->

</entry>
</feed>

虽然您对 Atom 的语义可能有点陌生,但使用 Grails 生成 Atom feed 的方法应该很简单。

验证 Atom feed

为了验证这个 feed 是格式良好的 Atom,访问 W3C 的在线 Feed Validator(见 参考资料)。如果 feed 在一个公共可访问的 URI 上,那么可以将它粘贴到主页上,并单击 Check。您的 Atom feed 是在 localhost 上运行的,所以单击 Validate by Direct Input,并粘贴 feed 的输出。结果如图 4 所示:

图 4. W3C 验证器
W3C 验证器
W3C 验证器

这里没有警告说 self 链接在提供的 URI 上不可用 — 事实显然正是如此 — 您的 Atom feed 应该被认定为是有效的、可生成的。

添加 feed 图标

接下来再来个锦上添花,将该 feed 的链接添加到头部。可以从网上很多地方下载无处不在的 feed 图标;它是在开源 Mozilla 许可下发布的(见 参考资料)。

将该文件复制到 web-app/images 中,然后调整 grails-app/views/layouts/_header.gsp,如清单 22 所示:

清单 22. 将 feed 图标添加到头部
<div id="header">
  <p><g:link class="header-main" controller="entry">Blogito</g:link></p>
  <p class="header-sub">
    <g:link controller="entry" action="atom">
    <img src="${createLinkTo(
        dir:'images',file:'feed-icon-28x28.png')}" alt="Subscribe" title="Subscribe"/>
    </g:link>
    A tiny little blog
  </p>

  <div id="loginHeader">
    <g:loginControl />
  </div>
</div>

结果应该是一个如图 5 所示的主页:

图 5. 含有 feed 图标的 Blogito 主页
含有 feed 图标的 Blogito 主页
含有 feed 图标的 Blogito 主页

结束语

在本文中,您添加了文件上传功能,以及一个 Atom 聚合 feed。至此,Blogito 是一个可以使用的 非常小的博客服务器。它到底有多小?两个 domain 类,两个控制器,刚刚超过 250 行代码。可以输入 grails stats 进行验证。清单 23 显示了代码的行数。

清单 23. Blogito 的大小
$ grails stats

	+----------------------+-------+-------+
	| Name                 | Files |  LOC  |
	+----------------------+-------+-------+
	| Controllers          |     2 |   127 |
	| Domain Classes       |     2 |    34 |
	| Tag Libraries        |     3 |    66 |
	| Unit Tests           |     6 |    24 |
	| Integration Tests    |     1 |    10 |
	+----------------------+-------+-------+
	| Totals               |    14 |   261 |
	+----------------------+-------+-------+

虽然这个练习占用了 4 篇文章的篇幅,但是,如果您有较扎实的 Grails 方面的应用知识,实际上它只需要一天的开发工作量。

希望您享受到将 Blogito 拼合起来的乐趣。下一次,您将通过相应的插件添加对评论、标签等功能的支持。在随后一期文章中,我将进一步与您一起探索 Grails 插件。到那时,请享受精通 Grails 的乐趣吧!


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology, Web development, Open source
ArticleID=406683
ArticleTitle=精通 Grails: 文件上传和 Atom 联合
publish-date=07062009