精通 Grails: 用定制 URI 和 codec 优化 Grails 中的 URI
在 “改变 Grails 应用程序的外观” 一文中,我们看到了如何使用层叠样式表(CSS)对一个 Grails 应用程序 — Blogito blog 站点 — 进行外观更改。这次,我将向您展示如何影响 Web 应用程序的命脉: 用于导航的 URI。这对于像 Blogito 这样的 weblog 极其重要。指向单个条目的那些永久链接(permalink)被像名片一样在 Internet 上传递;描述性越好,就越有效。
要获得描述性更好的 URI,需要定制控制器代码以支持个性化的 URI。还需要处理 UrlMappings.groovy 文件来创建新的路径。最后,您将创建一个定制的 codec 来更为轻松地生成定制 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 的要求。
Blogito 虽然已经支持条目,但它尚不支持用户。因此,必须先创建一个新的 User
类。
|
首先,在命令行提示符键入 grails create-domain-class User
。接下来,将清单 1 内的代码添加到 grails-app/domain/User.groovy:
class User { static constraints = { login(unique:true) password(password:true) name() } static hasMany = [entries:Entry] String login String password String name String toString(){ name } }
login
和 password
字段的作用不言自明;它们用来处理身份验证。name
字段用于显示的目的。比如,如果用 jsmith 登录,将会显示 “Jane Smith”。正如您所见,User
和 Entry
之间存在着一对多的关系。
将 static belongsTo
字段添加到 grails-app/domain/Entry.groovy,以完成一对多的关系,如清单 2 所示:
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 所示:
<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:
现在 Blogito 可以支持多个用户,下一步是让读者能按作者来查看这些条目。
我们的最终目的就是支持像 http://localhost:9090/blogito/entry/list/jdoe 这样的 URI。注意到,User.login
出现在此 URI 内,而不是主键。在这个过程中,还需要对分页(pagination)做稍许调整。
EntryController.list
的搭建(scaffolded)行为不允许按 User
过滤。清单 5 显示了 list
闭包的默认实现:
def list = { if(!params.max) params.max = 10 [ entryInstanceList: Entry.list( params ) ] } |
若要支持在路径的末尾允许出现一个可选的用户名,还需要对之进行扩展。编辑 grails-app/controllers/EntryController.groovy 并添加一个新的 list
闭包,如清单 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.max
和 params.id
二者的值,就用默认值填充。现在,先不要担心 flash.id
— 我稍后在探讨有关分页问题的时候还会对之进行详细讨论。
params.id
值通常是一个整型 — 确切的说是主键。我们一般习惯于 /entry/show/1 和 entry/edit/2 这样的 URI。我本可以在 grails-app/conf/UrlMappings.groovy 内设置一个映射以便返回一个描述性更好的名称,比如 params.name
或 params.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 所示:
<div class="paginateButtons"> <g:paginate total="${entryCount}" params="${flash}"/> </div> |
重启 Grails 并在 Web 浏览器内访问 http://localhost:9090/blogito/entry/list/jsmith。屏幕应该类似图 2:
为了确保分页仍能工作,键入 http://localhost:9090/blogito/entry/list/jsmith?max=1
。单击 Previous 和 Next 按钮以确保只有 Jane 的 blog 条目才会出现,如图 3 所示:
按作者过滤的功能就绪后,就可以更进一步,创建一个更为友好的定制 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。
在使用 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:HtmlCodec
、UrlCodec
、Base64Codec
和 JavaScriptCodec
(参见 参考资料)。HtmlCodec
是所生成的 GSP 文件内的 encodeAsHtml()
和 decodeHtml()
方法的源代码。
您也可以向其中添加您自己的 codec。Grails 使用 grails-app/utils 目录内任何一个具有 Codec
后缀的类来将 encodeAs()
和 decode()
方法添加到 String
。在本例中,Blogito 内的所有 String
都魔法般地具有了两个新方法:encodeAsUnderscore()
和 decodeUnderscore()
。
通过在 test/integration 内创建 UnderscoreCodecTests.groovy 可以验证这一点,如清单 11 所示:
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:
$ grails test-app ------------------------------------------------------- Running 2 Integration Tests... Running test UnderscoreCodecTests... testEncode...SUCCESS testDecode...SUCCESS Integration Tests Completed in 157ms -------------------------------------------------------
UnderscoreCodec
也就绪后,您就可以支持在 URI 中包括用户和条目标题 — 比如,http://localhost:9090/blogito/blog/jsmith/this_is_my_latest_entry。
首先,调整 UrlMappings.groovy 内的 /blog 映射以支持一个可选的 $title
,如清单 13 所示。还记得么,在 Groovy 内,尾部加个问号代表这是可选的。
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 所示:
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:
URI 是一个 Web 应用程序的命脉。Grails 的默认设置是一个很好的开端,但是您也应习惯于定制这些 URI 以最好地满足您的 Web 站点的要求。得益于您的艰苦工作,Blogito 现在具有了 Users
和 Entries
。但更为重要的是,您对 URI 使用其他内容而不是主键来查看它们。您了解了如何通过调整控制器代码创建更为友好的 URI、向 UrlMappings.groovy 添加映射以及创建一个定制 codec。
下一次,您将创建一个登录表单以便能对 Blogito Users
进行身份验证。一旦用户登录,他们就能上传一个文件用作 blog 条目的主体 — HTML、一个图像或是一个 MP3 文件。到那时,就可以享受精通 Grails 带来的乐趣了。