精通 Grails: 用定制 URI 和 codec 优化 Grails 中的 URI

在 “改变 Grails 应用程序的外观” 一文中,我们看到了如何使用层叠样式表(CSS)对一个 Grails 应用程序 — Blogito blog 站点 — 进行外观更改。这次,我将向您展示如何影响 Web 应用程序的命脉: 用于导航的 URI。这对于像 Blogito 这样的 weblog 极其重要。指向单个条目的那些永久链接(permalink)被像名片一样在 Internet 上传递;描述性越好,就越有效。

要获得描述性更好的 URI,需要定制控制器代码以支持个性化的 URI。还需要处理 UrlMappings.groovy 文件来创建新的路径。最后,您将创建一个定制的 codec 来更为轻松地生成定制 URI。

了解 URI

URI 中的 U 在正式的场合下代表的是 Uniform,但是也可以表示 Unique(参见 参考资料)。如果 URI http://www.ibm.com/developerworks 不能确切标识您目前所处于的 Web 站点,它就没什么用处了。它还能使资源标识符 更容易让人记住。通过键入 http://129.42.56.216 虽然可以进入该站点,但是很少有人愿意去记忆这个 Web 站点的用圆点分隔的数字形式的 IP 地址。

所以,URI 至少必须是惟一的。理想情况下,它还应该容易被人记住。(参见 围绕模糊 URI 的争论 侧栏获得对容易被人记住的 URI 的不同看法)。Grails 绝对能够满足第一个要求。它综合使用了控制器名称、闭包名称以及数据库记录的主键以确保 URI 的惟一性。比如,如果想要向用户显示数据库内的第一个 Entry,就让他们在其浏览器内键入 http://localhost:9090/blogito/entry/show/1

虽然在 URI 内包含主键的默认设置十分合理,但我认为它还是在两个方面违背了美学标准。首先,实现牵涉的内容较多。这个附带的数据库工件贯穿了整个 Web 站点。Google、Amazon 和 eBay 都在后台使用了数据库,但是很难在它们的 URI 内找到任何数据库的迹象。其次,从 URI 删除主键是出于语义的要求。Jane Smith 的 blog 的读者更愿意用 jsmith 作为她的标识,而不是一个数字 12。同样地,按标题而不是主键列出 blog 条目更能满足可记忆 URI 的要求。

创建 User

Blogito 虽然已经支持条目,但它尚不支持用户。因此,必须先创建一个新的 User 类。

精通 Grails: 用定制 URI 和 codec 优化 Grails 中的 URI
围绕模糊 URI 的争论

所有人都同意一个 URI 必须能够惟一识别一种资源,但是,围绕它是否应该为了可读性而提供额外元数据的争论仍然十分激烈(参见 参考资料)。有些人认为加重 URI 的负担,使其既具有惟一性又具描述性十分危险。他们认为描述性好的 URI 太长且太脆弱,而且还不必要地将资源标识符与底层技术连接起来。

上述这些担心都很合理,但是我却对 URI 不透明性的公认不敢苟同。我认为可读的 URI 对用户更为友好,而且利远远大于弊。清晰的 URI 容易记,若遇到问题,也容易调试,而且如果它们遵循了透明约定,还能使 Web 站点的自描述更好而且更易于被发现。

Grails 争取透明性的第一步是在 URI 内公布对象名和控制器方法。在本文中,我将通过用更为友好的文本标识符代替主键来继续这场有关其合理性的争论。但是为了证明我能看到问题的两个方面的优点,在需要简明 URI 而不是描述性更好的 URI 时,我衷心赞同使用类似 tinyurl.com 这样的 Web 站点(参见 参考资料)。

首先,在命令行提示符键入 grails create-domain-class User。接下来,将清单 1 内的代码添加到 grails-app/domain/User.groovy:


清单 1. User

class User { static constraints = { login(unique:true) password(password:true) name() } static hasMany = [entries:Entry] String login String password String name String toString(){ name } }

loginpassword 字段的作用不言自明;它们用来处理身份验证。name 字段用于显示的目的。比如,如果用 jsmith 登录,将会显示 “Jane Smith”。正如您所见,UserEntry 之间存在着一对多的关系。

static belongsTo 字段添加到 grails-app/domain/Entry.groovy,以完成一对多的关系,如清单 2 所示:


清单 2. 向 Entry 类添加一对多的关系

				
class Entry {
  static belongsTo = [author:User]
  //snip
}

我们注意到,在定义关系时,可以很容易地重命名此字段。User 类具有一个名为 entries 的字段。Entry 类现在具有一个名为 author 的字段。

通常,在此时,都会创建一个相关的 UserController 以提供一个完整的 UI 来管理 Users。我却没有打算这么做。我只是想用几个无存根的 Users 作为占位符。在下一篇 精通 Grails 的文章中,您将更为全面地了解用户身份验证和授权的相关内容。因此,我们走 “刚刚好” 的路线,通过使用 grails-app/conf/BootStrap.groovy 添加几个新用户,如清单 3 所示:


清单 3. 在 BootStrap.groovy 中使用无存根 Users

import grails.util.GrailsUtil class BootStrap { def init = { servletContext -> switch(GrailsUtil.environment){ case "development": def jdoe = new User(login:"jdoe", password:"password", name:"John Doe") def e1 = new Entry(title:"Grails 1.1 beta is out", summary:"Check out the new features") def e2 = new Entry(title:"Just Released - Groovy 1.6 beta 2", summary:"It is looking good.") jdoe.addToEntries(e1) jdoe.addToEntries(e2) jdoe.save() def jsmith = new User(login:"jsmith", password:"wordpass", name:"Jane Smith") def e3 = new Entry(title:"Codecs in Grails", summary:"See Mastering Grails") def e4 = new Entry(title:"Testing with Groovy", summary:"See Practically Groovy") jsmith.addToEntries(e3) jsmith.addToEntries(e4) jsmith.save() break case "production": break } } def destroy = { } }

请注意,我是如何将条目分配给一个 User 的。您无需担心处理主键或外键的麻烦。Grails Object Relational Mapping (GORM) API 让您能从对象的角度而不是关系数据库理论来进行思考。

接下来,对在 上一篇 文章中所创建的 grails-app/views/entry/_entry.gsp 局部模板稍作处理。在 Entry.lastUpdated 字段的旁边显示作者,如清单 4 所示:


清单 4. 向 to _entry.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> </div>

${entryInstance.author}User 类上调用 toString() 方法。也可以使用 ${entryInstance.author.name} 来显示您所选择的字段。还可以使用此语法随心所欲地遍历这些类的嵌套结构。

现在,我们就可以看看所做的这些变更的实际效果了。键入 grails run-app 并在 Web 浏览器内访问 http://localhost:9090/blogito/。屏幕应该类似于图 1:


图 1. 显示了新添加的作者的条目

精通 Grails: 用定制 URI 和 codec 优化 Grails 中的 URI

现在 Blogito 可以支持多个用户,下一步是让读者能按作者来查看这些条目。

按作者显示条目

我们的最终目的就是支持像 http://localhost:9090/blogito/entry/list/jdoe 这样的 URI。注意到,User.login 出现在此 URI 内,而不是主键。在这个过程中,还需要对分页(pagination)做稍许调整。

EntryController.list 的搭建(scaffolded)行为不允许按 User 过滤。清单 5 显示了 list 闭包的默认实现:


清单 5. 默认的 list 实现

				
def list = {
    if(!params.max) params.max = 10
    [ entryInstanceList: Entry.list( params ) ]
}

若要支持在路径的末尾允许出现一个可选的用户名,还需要对之进行扩展。编辑 grails-app/controllers/EntryController.groovy 并添加一个新的 list 闭包,如清单 6 所示:


清单 6. 按作者限制此列表

class EntryController { def scaffold = Entry def list = { if(!params.max) params.max = 10 flash.id = params.id if(!params.id) params.id = "No User Supplied" def entryList def entryCount def author = User.findByLogin(params.id) if(author){ def query = { eq('author', author) } entryList = Entry.createCriteria().list(params, query) entryCount = Entry.createCriteria().count(query) }else{ entryList = Entry.list( params ) entryCount = Entry.count() } [ entryInstanceList:entryList, entryCount:entryCount ] } }

您应该注意到的第一件事情是,若终端用户没有提供 params.maxparams.id 二者的值,就用默认值填充。现在,先不要担心 flash.id — 我稍后在探讨有关分页问题的时候还会对之进行详细讨论。

params.id 值通常是一个整型 — 确切的说是主键。我们一般习惯于 /entry/show/1 和 entry/edit/2 这样的 URI。我本可以在 grails-app/conf/UrlMappings.groovy 内设置一个映射以便返回一个描述性更好的名称,比如 params.nameparams.login,但现有的映射已经获取了操作名称后的路径元素并将其存储在 params.id 内。我只是充分利用了现有的行为。在 URLMapper.groovy 内,如清单 7 所示,可以看到返回 params.id 的默认映射:


清单 7. UrlMappings.groovy 内的默认映射
class UrlMappings { static mappings = { "/$controller/$action?/$id?"{ constraints {} } //snip } }

由于这不是 User 的主键,所以不能像往常那样使用 User.get(params.id)。相反,必须使用 User.findByLogin(params.id)

如果找到了一个匹配的 User,就需要创建一个查询块。这就需要用到 Hibernate Criteria Builder(参见 参考资料)。在本例中,我们限制了列表只包含匹配某特定作者的那些条目。同样地,我们注意到 GORM 也允许您从对象而不是主键或外键的角度来思考。

如果没有匹配 params.id 的作者,就会返回全部条目的完整列表: entryList = Entry.list( params )

注意,entryCount 值是被显式计算出来的。Scaffolded GroovyServer Pages (GSP) 代码通常会在 <g:paginate> 标记内调用 Entry.count()。由于会传递回一个过滤了的列表,所以需要在此控制器的一个变量内处理这一点。

flash.id 内存储 params.id 值将允许应用程序将此查询条件传递回 <g:paginate> 标记。调整 grails-app/views/entry/list.gsp 内的 <g:paginate> 以便利用新的 entryCount 变量以及存储在 flash 范围内的参数,如清单 8 所示:


清单 8. 针对定制分页调整 list.gsp 页面

				
<div class="paginateButtons">
  <g:paginate total="${entryCount}" params="${flash}"/>
</div>

重启 Grails 并在 Web 浏览器内访问 http://localhost:9090/blogito/entry/list/jsmith。屏幕应该类似图 2:


图 2. 按作者列出条目

精通 Grails: 用定制 URI 和 codec 优化 Grails 中的 URI

为了确保分页仍能工作,键入 http://localhost:9090/blogito/entry/list/jsmith?max=1。单击 PreviousNext 按钮以确保只有 Jane 的 blog 条目才会出现,如图 3 所示:


图 3. 测试定制分页

精通 Grails: 用定制 URI 和 codec 优化 Grails 中的 URI

按作者过滤的功能就绪后,就可以更进一步,创建一个更为友好的定制 URI。

创建一个定制 URI

UrlMappings.groovy 文件为创建新的 URI 提供了额外的灵活性。虽然 http://localhost:9090/blogito/entry/list/jsmith 已经可以发挥作用,但是假设,最新出现的用户请求要求支持 http://localhost:9090/blogito/blog/jsmith 这样的 URI,又该如何呢?没问题!如清单 9 所示那样向 UrlMappings.groovy 添加一个新的映射:


清单 9. 向 UrlMappings.groovy 添加一个新的定制映射

class UrlMappings { static mappings = { "/$controller/$action?/$id?"{ constraints { // apply constraints here } } "/"(controller:"entry") "/blog/$id"(controller:"entry", action="list") "500"(view:'/error') } }

现在,以 /blog 开头的那些 URI 都将会被重新定向到条目控制器和列表动作。虽然 $user$login 的描述性可能更好,但是让 $id 与 Grails 约定保持一致就意味着 "/$controller/$action?/$id?""/blog/$id"(controller:"entry", action="list") 二者能够指向同一个端点。

在 Web 浏览器内键入 http://localhost:9090/blogito/blog/jsmith 以验证此映射能够工作。

处理好 Users 之后,就可以集中精力为 Entries 创建更友好的 URI。

创建一个定制 codec

在使用 User.login 而非 User.id 时,URI 很简单,因为它不包含空白。不错,目前尚没有任何的验证规则强制这种 “无空白” 的要求,但我们可以很轻松地添加一个这样的规则来强制 URI 遵从这一要求(参见 参考资料)。

但是,若在 URI 内用 Entry.title 代替 Entry.id 又如何呢?标题几乎都要包含空白。一种解决方法是向 Entry 类内添加另一个字段并让终端用户重新输入没有空白的标题。这种做法不是很理想,因为它要求用户做更多的工作,而且还要求必须要编写另一个验证规则来确保用户能正确输入。更好的方法是让 Grails 根据使用 Entry.title 的位置自动将空白转变为下划线。要实现此目的,需要创建一个定制 codec(即 编码-解码器 的简写)。

创建 grails-app/utils/UnderscoreCodec 并添加清单 10 所示代码:


清单 10. 一个定制 codec
class UnderscoreCodec { static encode = {target-> target.replaceAll(" ", "_") } static decode = {target-> target.replaceAll("_", " ") } }

Grails 提供了几个开箱即用的内置 codec:HtmlCodecUrlCodecBase64CodecJavaScriptCodec(参见 参考资料)。HtmlCodec 是所生成的 GSP 文件内的 encodeAsHtml()decodeHtml() 方法的源代码。

您也可以向其中添加您自己的 codec。Grails 使用 grails-app/utils 目录内任何一个具有 Codec 后缀的类来将 encodeAs()decode() 方法添加到 String。在本例中,Blogito 内的所有 String 都魔法般地具有了两个新方法:encodeAsUnderscore()decodeUnderscore()

通过在 test/integration 内创建 UnderscoreCodecTests.groovy 可以验证这一点,如清单 11 所示:


清单 11. 测试一个定制 codec

class UnderscoreCodecTests extends GroovyTestCase { void testEncode() { String test = "this is a test" assertEquals "this_is_a_test", test.encodeAsUnderscore() } void testDecode() { String test = "this_is_a_test" assertEquals "this is a test", test.decodeUnderscore() } }

在命令行提示符键入 grails test-app 运行测试。所看到的结果应该类似清单 12:


清单 12. 测试成功运行后的输出

$ grails test-app ------------------------------------------------------- Running 2 Integration Tests... Running test UnderscoreCodecTests... testEncode...SUCCESS testDecode...SUCCESS Integration Tests Completed in 157ms -------------------------------------------------------

运行中的 Codec

UnderscoreCodec 也就绪后,您就可以支持在 URI 中包括用户和条目标题 — 比如,http://localhost:9090/blogito/blog/jsmith/this_is_my_latest_entry。

首先,调整 UrlMappings.groovy 内的 /blog 映射以支持一个可选的 $title,如清单 13 所示。还记得么,在 Groovy 内,尾部加个问号代表这是可选的。


清单 13. 在 URI 映射内允许可选标题

class UrlMappings { static mappings = { "/$controller/$action?/$id?"{ constraints { // apply constraints here } } "/"(controller:"entry") "/blog/$id/$title?"(controller:"entry", action="list") "/entry/$action?/$id?/$title?"(controller:"entry") "500"(view:'/error') } }

接下来,调整 EntryController.list 来说明新的 params.title 值,如清单 14 所示:


清单 14. 处理控制器内的 params.title

class EntryController { def scaffold = Entry def list = { if(!params.max) params.max = 10 flash.id = params.id if(!params.id) params.id = "No User Supplied" flash.title = params.title if(!params.title) params.title = "" def author = User.findByLogin(params.id) def entryList def entryCount if(author){ def query = { and{ eq('author', author) like("title", params.title.decodeUnderscore() + '%') } } entryList = Entry.createCriteria().list(params, query) entryCount = Entry.createCriteria().count(query) }else{ entryList = Entry.list( params ) entryCount = Entry.count() } [ entryInstanceList:entryList, entryCount:entryCount ] } }

我已经在此查询内使用了 like 以让此 URI 更为灵活。例如,用户可以键入 /blog/jsmith/mastering_grails 来返回所有以 mastering_grails 开头的标题。如果您愿意更为严格一些,可以使用此查询内的 eq 方法来要求一个确切的匹配。

在 Web 浏览器内键入 http://localhost:9090/blogito/jsmith/Codecs_in_Grails 来观察运行中的这个新的 codec。您的屏幕应该类似图 4:


图 4. 按用户名和标题查看一个 blog 条目

精通 Grails: 用定制 URI 和 codec 优化 Grails 中的 URI

结束语

URI 是一个 Web 应用程序的命脉。Grails 的默认设置是一个很好的开端,但是您也应习惯于定制这些 URI 以最好地满足您的 Web 站点的要求。得益于您的艰苦工作,Blogito 现在具有了 UsersEntries。但更为重要的是,您对 URI 使用其他内容而不是主键来查看它们。您了解了如何通过调整控制器代码创建更为友好的 URI、向 UrlMappings.groovy 添加映射以及创建一个定制 codec。

下一次,您将创建一个登录表单以便能对 Blogito Users 进行身份验证。一旦用户登录,他们就能上传一个文件用作 blog 条目的主体 — HTML、一个图像或是一个 MP3 文件。到那时,就可以享受精通 Grails 带来的乐趣了。