天天看点

《Clojure Web开发实战》——第2章,第2.3节应用架构

本节书摘来自异步社区《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"&gt;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&gt;} ("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 [&amp; body]

 [:html

   [:head

   [:title "welcome"]]

   [:body body]])`

同样,在hiccup.page命名空间里,hiccup提供若干生成特定html变体的宏,比如html4、html5和xhtml。看,我们在留言簿程序里使用的就是html5宏。

`(defn common [&amp; 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 [&amp; 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将日期对象转化为格式化字符串。我们使用流化(-&gt;)宏去执行格式化器去格式化文本,接下来使用此方法处理从数据库获取的时间戳。

(defn format-time [timestamp]

 (-&gt; "dd/mm/yyyy"

   (java.text.simpledateformat.)

   (.format timestamp)))

你可能已经发现目前的home函数编写得有点复杂,因为它还有一些用来指导用户提交表单的额外描述。

这里有一点值得一提:错误处理行的代码用于显示错误键值,由控制器填充,最终交由show-guests函数去呈现内容。

home函数使用layout/common封装内容,为页面生成html。

(defn home [&amp; [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提供的,真实的应用还需要添加一些别的元素。接下来,让我们看看如何为我们的应用添加更多功能。