本节书摘来自异步社区《clojure web开发实战》一书中的第2章,第2.3节应用架构,作者[美]dmitri sotnikov,更多章节内容可以访问云栖社区“异步社区”公众号查看
2.3 应用架构
典型的compojure开发web程序方式可能不同于你之前使用的方式。多数框架偏好使用模型-视图-控制器(mvc,model-view-controller)模式使用逻辑分离思想将视图、控制、模式严格分开。这里,compojure并没有明确分离视图和控制。
相反,我们为程序中每个路由创建了独立的handler,这些handler用于处理来自客户端的http请求,compojure正是以这种思路来分派任务的。handler驱动模型负责处理域逻辑。这种方法提供了一个彻底的域逻辑分离模式,并不牵涉应用程序的表示层,也没有任何不必要的联系。
尽管如此,clojure的web栈设计得还是比较灵活,它甚至允许你以任何喜好的方式来组织,如果你非要在程序中使用传统mvc风格,也不会有什么麻烦。
仅通过几个逻辑部件就能一览典型应用(这是指我们前面做的那个留言簿程序的结构)。那我们再看看别的一些特性,多数应用被拆分为如下几个方面。
• handler——此命名空间负责处理请求、响应。
• routes——路由涵盖我们程序的核心内容,譬如维护读取页面和处理客户端请求的逻辑关系。
• model——此命名空间保留给数据模型和持久化层。
• views——此命名空间包含通用逻辑以构成应用层。
程序的handler
handler是功能入口,它通常用于定义handler命名空间。它负责将程序的所有路由汇聚起来,并且定义所有的处理过程,用于封装必要的中间件。
handler命名空间也为程序定义一些基础路由,但不用于任何特定的工作流。我们留言簿程序中的那个handler,有两条路由:一条用于处理静态资源;还有一条用于捕获其他所有路由都未定义的uri请求。
`(defroutes app-routes
(route/resources "/")
(route/not-found "not found"))`
路由里具体的工作流,比如在留言簿里发布和浏览消息的路由处理,都组织在与它们功能相关的特定命名空间里。每一条都供routes命名空间访问。
handler命名空间也提供init和destroy方法,它们在程序起停时被调用。任何需要在始末阶段调用的代码,都要分别放在这两个函数里面执行。
举个例子说明吧,我们在留言簿程序里就用上了,init函数用来检查数据库连接是否可用。
`(defn init []
(println "guestbook is starting")
(if-not (.exists (java.io.file. "./db.sq3"))
(db/create-guestbook-table)))`
接下来,我们定义入口点,在调用app函数时,程序将开始处理所有路由请求。
<code>(def app (handler/site (routes home-routes app-routes)))</code>
这段代码,compojure.handler/site函数用于生成ring handler,用中间件支撑一个典型网站。
site函数仅仅创建一个handler,并将其封装进一些通用中间件,来支持通用网站。中间件由如下封装器构成。
• wrap-session。
• wrap-flash。
• wrap-cookies。
• wrap-multipart-params。
• wrap-params。
• wrap-nested-params。
• wrap-keyword-params。
在project.clj里,程序的handler、init函数、destroy函数,都绑定在:ring键下面,具体参见我们的留言簿程序(“第1章起步”)。
`:ring {:handler guestbook.handler/app
:init guestbook.handler/init
:destroy guestbook.handler/destroy}`
以上描述用于引导程序核心部分。接下来,我们一起看看怎样添加一些别的路由,来满足应用程序的具体功能。
路由请求
此前我们讨论过,程序路由表现为uri,由客户端请求,由服务端执行。客户端请求的uri由路由程序对应的处理函数做相应回应。
现实当中没有哪个应用只有一条路由。比如,在我们的留言簿程序中,有两个独立路由,各自执行不同的操作:
`guestbook/src/guestbook/routes/home.clj
(defroutes home-routes
(get "/" [](home))
第一条路由被绑定于/,用于从数据库检索消息,并用此消息创建一张表单,最终呈现整幅页面给客户端。
第二条路由会处理用户输入。如果输入验证通过,接下来这条消息就会被存入数据库;否则,页面将呈现错误描述。
其实这两条路由功能有交集:存储和显示用户信息,它们也算是同一工作流的两个部分。
当你发现程序的工作流有明确所属,那么可以将此工作流的逻辑关系合并,放在一起处理。程序中的routes包之下的命名空间正是为这种特殊工作流预留的。
由于我们的留言簿应用很小。除了在guestbook.routes.home命名空间里有几个辅助函数,定义一套路由就够用了。
当程序包含多个页面,为便于维护代码,我们会创建额外的命名空间。接下来我们用compojure提供的routes宏,在每个独立的命名空间下创建独立的路由,并将处理放在handler命名空间。
routes宏可以将多个路由合并,最终创建handler。有一点要注意,路由之间存在覆盖关系。由于我们的app-routes调用了(route/not-found "not found"),务必把它置为最后一条,否则在not-found路由后面的所有路由将被覆盖。
应用模型
稍稍复杂一些的应用,都需要建立在某种模型之上。模型用于描述应用程序如何存储数据、单个数据元素之间的内在关系。我们的留言簿程序模型由用户表和消息表构成。
处理模型和持久层的所有命名空间,惯例上属于models包。我们在下一章会用大篇幅重点讲述。
应用视图
views包用于为页面提供可视布局和其他的通用控件,其下有预设的layout命名空间。这个命名空间为我们包含了common布局声明,用于生成基础页面模板。
common布局用于填充页面头、填写标题标签、打包资源(如css)及添加负载内容。由于内容使用html5宏封装,common布局被调用之后,将自动创建html文本串,这个处理直接将结果反馈给客户端。
这种方式常用于创建通用布局,以及提供基本页面结构,也使用它定义个别页面。亦可创建通用页面元素,比如页眉、页脚、菜单,并会得到统一维护。我们每次创建的页面,都需要使用定义的布局简单将内容包裹起来。
定义页面
创建路由的同时也就定义了页面,通过接受请求参数来生成各种特殊的响应,比如用来返回html元素,执行服务端操作,重定向到另一个页面;或者返回特殊类型的数据,比如数据交换格式(json,javascript object notation)字符串或文件。
通常,一张页面由多条路由组成。其中有一条接受get请求,并返回html供浏览器渲染的路由。还有其他情况,比如在客户端用户与页面交互时,生成并提交了表单,这时会有其他路由来处理此请求。
无论我们选择如何处理,都能创建页面,compojure并不关心我们使用的具体方法,这恰好为选择模板库留有余地。可选的方案不少,这里介绍几个流行的库:hiccup14、enlive15、selmer16、stencil17。
hiccup能使用原生clojure数据结构,通过它定义表情并生成相适应的html;enlive反其道而行,使用纯html定义页面而不用特殊处理标签。适配器将特定模型和域变换为html模板。
与hiccup和enlive不一样,stencil和selmer都是基于外部模板系统,而不是基于clojure。stencil是实现了mustache(这是个流行的无逻辑模板系统),selmer是模仿django模板系统在python上的实现。
本书重点关注并使用hiccup,因为它不需要额外学习任何语法,直接使用clojure函数即可。此外,我们在后面还会学习用selmer模板来取代hiccup创建的应用。
别的选择彻底没有考虑使用服务端模板,你需要在客户端处理模板来接管这些工作,挑个流行的javascript库,并使用ajax与服务通讯。当然,这样也能胜任。好处是这可以让客户端服务端的界限明确、清晰,有助于扩充其他形式的客户端,比如移动应用接口。在编写单页应用18时,这还是通行手段。
无论你喜欢何种模板策略,最佳实践都不会去聚合域逻辑和视图。通过合理构架的程序,是可以轻松替换模板引擎的。
hiccup处理模板化页面
现在开始介绍一些hiccup使用基础,以及通过它如何生成适当的页面元素。
刚才提到,用原生clojure就能编写hiccup模板,所以你就不需要去学习特定领域语言(dls,domain-specific language)就能驾驭它。
hiccup用clojure vector(向量表)表示html元素,其属性使用map描述,这种结构表达方式与生成的html标签在结构上比较吻合,示例如下。
`[:tag-name {:attribute-key "attribute value"} tag body]
attribute-key="attribute value">tag body`
如果我们想要创建一个包含图片的div标签,可以创建一个vector,第一个元素为:div关键字,紧随其后是一个map(包含div id和div的class)。余下部分是以vector表示图片的内容构成。
<code>[:div {:id "hello", :class "content"} [:p "hello world!"]]</code>
我们使用hiccup.core/html宏将vector转换为html文本:
<code>(html [:div {:id "hello", :class "content"} [:p "hello world!"]])</code>
hello world!
由于hiccup允许你通过map设置元素属性,如有必要,你还可以使用元素内联样式。尽管如此,你还是应该抵御这种诱惑,使用css样式化元素取代之,这可以确保结构和描述分离。
由于对元素设置id和设置class是常用操作,hiccup还提供便捷的css样式化处理。我们可以如下简化编写我们的div,取代之前的代码:
<code>[:div#hello.content [:p "hello world!"]]</code>
hiccup同样提供一些辅助函数,用来定义常用元素,比如表单、链接、图像。所有这些函数输出的vector,由hiccup预先定义的格式描述。
当一个函数在使用中并不能满足需求时,你当然可以写下元素的文本描述,还可以调整输出来满足需要。描述html元素的函数可以配置,其第一个参数可以接受可选属性的map。我们再了解一些常用的hiccup辅助函数,来改善使用体验。
首先,我们来看看怎么用link-to辅助函数创建一个标签:
(link-to {:align "left"} "http://google.com" "google")
这段代码将生成以下vector:
[:a {:align "left", :href #http://google.com>} ("google")]
我们已有一个关键字:a作为第一项,紧随其后的map表示属性,以及表示内容的list。
还是如此,将link-to函数封装在html宏里面,我们可以基于此vector输出html:
(html (link-to {:align "left"} "http://google.com" "google"))
<a href="http://google.com">google</a>
还有一个常用的函数form-to,用来生成html表单,我们用此函数实现上一章创建的表单,并将信息提交给服务端。
`(form-to [:post "/"]
[:p "name:" (text-field "name")]
[:p "message:" (text-area {:rows 10 :cols 40} "message")]
(submit-button "comment"))`
这个辅助函数接受一个vector,第一个元素是http请求类型的关键字,第二个元素是url字符串。余下参数也为vector,通过求值可以表示为html元素。当调用html宏后,前面的代码会被转化为以下html:
`
name:
message:
还有一个实用的辅助宏defhtml。我们在定义一个函数同时,通过参数内容悄悄生成html。这意味着在构造页面时,我们不需要用html宏作用每一个独立元素。
`(defhtml page [& body]
[:html
[:head
[:title "welcome"]]
[:body body]])`
同样,在hiccup.page命名空间里,hiccup提供若干生成特定html变体的宏,比如html4、html5和xhtml。看,我们在留言簿程序里使用的就是html5宏。
`(defn common [& body]
(html5
[:head
[:title "welcome to guestbook"]
(include-css "/css/screen.css")]
[:body body]))`
添加资源
现实中,大型网站的页面必然涉及加载javascript和css。在hiccup.page 命名空间里,hiccup提供几个实用函数来达到这个目的。你可以使用include-css去引用任何css文件,include-js来加载javascript资源。这里有个在常用布局中包含css 和javascript资源的例子:
`(defn common [& content]
[:title "my app"]
(include-css "/css/mobile.css"
"/css/screen.css")
(include-js "//code.jquery.com/jquery-1.10.1.min.js"
"/js/uielements.js")]
[:body content]))`
如你所见,include-css和include-js都能接受多个字符串,每个参数指定一个uri资源。它们的输出必然是一个hiccupvector,最终会被转换为html。
;;output of include-css
([:link
{:type "text/css", :href #, :rel "stylesheet"}]
[:link
{:type "text/css", :href #, :rel "stylesheet"}])
;;output of include-js
([:script
{:type "text/javascript",
:src
#}]
[:script {:type "text/javascript", :src #}])
同样,在hiccup.element命名空间,hiccup提供一个名为image的辅助函数去加载图片:
(image "/img/test.jpg")
[:img {:src #}]
(image "/img/test.jpg" "alt text")
[:img {:src #, :alt "alt text"}]
hiccup api一览
你已经见识了一些常用的函数,其实还有一些更有用的。大多数辅助函数可以在element和form命名空间里找到。这些函数用于定义元素,比如图像、链接、脚本标签、复选框、下拉工具栏以及输入栏。
如你所见,hiccup提供一套简明api去生成html模板,此外还有字面量vector表达式。既然你已经领悟到了hiccup的精髓,那我们回过来对此前的留言簿程序进行更深入的剖析。
回顾留言簿程序
我们现在换个角度去看待那些定义在home命名空间的函数。当你试着运行程序,并来回浏览时,顺便查阅页面的html输出和在代码里的定义。
首先,我们用show-guests函数去生成一个无序清单。它遍历数据库的消息,然后为每一个消息创建一个列表项。
(defn show-guests []
[:ul.guests
(for [{:keys [message name timestamp]} (db/read-guests)]
[:li
[:blockquote message]
[:p "-" [:cite name]]
[:time (format-time timestamp)]])])
这里有个辅助函数,可以用于显示格式化时间戳。此函数使用java.text.simpledate format将日期对象转化为格式化字符串。我们使用流化(->)宏去执行格式化器去格式化文本,接下来使用此方法处理从数据库获取的时间戳。
(defn format-time [timestamp]
(-> "dd/mm/yyyy"
(java.text.simpledateformat.)
(.format timestamp)))
你可能已经发现目前的home函数编写得有点复杂,因为它还有一些用来指导用户提交表单的额外描述。
这里有一点值得一提:错误处理行的代码用于显示错误键值,由控制器填充,最终交由show-guests函数去呈现内容。
home函数使用layout/common封装内容,为页面生成html。
(defn home [& [name message error]]
(layout/common
[:h1 "guestbook"]
[:p "welcome to my guestbook"]
[:p error]
(show-guests)
[:hr]
(form-to [:post "/"]
[:p "name:" (text-field "name" name)]
[:p "message:" (text-area {:rows 10 :cols 40} "message" message)]
(submit-button "comment"))))
如你所见,仅需少许代码,就能使用hiccup创建页面模板,同时也便于通过关联模板定义生成输出元素。
我们就此完成了路由定义,compojure路由得以完善。
到目前为止,我们已完成创建路由并由此呈现页面,还能处理来自客户端的请求表单。正如我们先前提到的,除了由ring和compojure提供的,真实的应用还需要添加一些别的元素。接下来,让我们看看如何为我们的应用添加更多功能。