Clojure Web开发实战

978-7-115-39893-2
作者: 【美】Dmitri Sotnikov(肖特尼科夫)
译者: 张恒陈冀康
编辑: 陈冀康

图书目录:

详情

现代Web开发需要现代工具。本书介绍了如何应用Clojure的基础知识来构建现实的Web应用解决方案。介绍了用这一语言来开发Web应用的各个部分,使用Clojure开发Web的完整过程,该语言的最佳实践方法,以及在Light Table和Eclipse开发环境中使用Cloujre,学习如何使用Liberator库开发RESTful服务,以及在客户端和服务器端使用ClojureScript。

图书摘要

版权信息

书名:Clojure Web开发实战

ISBN:978-7-115-39893-2

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。


Dmitri Sotnikov,Dynamic Animation Systems的软件架构师,在分布式仿真,机器视觉,和专家系统建设方面经验丰富。他是Clojure,ClojureScript以及Underscore-contrib的贡献者。

本书卖点

现代Web开发需要现代工具。本书介绍了如何应用Clojure的基础知识来构建现实的Web应用解决方案。介绍了用这一语言来开发Web应用的各个部分,使用Clojure开发Web的完整过程,该语言的最佳实践方法,以及在Light Table和Eclipse开发环境中使用Cloujre,学习如何使用Liberator库开发RESTful服务,以及在客户端和服务器端使用ClojureScript。


Copyright © 2014 The Pragmatic Programmers, LLC. Original English language edition, entitled Web Development with Clojure.

Simplified Chinese-language edition Copyright © 2015 by Posts & Telecom Press.

All rights reserved.

本书中文简体字版由The Pragmatic Programmers, LLC授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。

版权所有,侵权必究。


Clojure是一门Lisp方言。它通过函数式编程技术,直接支持并发软件开发,得到众多开发人员的欢迎,广泛应用于各个领域。Web开发是Clojure的主战场之一。

本书专门探讨Clojure在Web开发领域的实际应用。通过阅读本书,读者既可以深入理解Clojure Web栈的专业知识,同时又能运用这些知识来轻松构建Web应用。全书共7章,详细介绍了Clojure Web开发的各个方面,附录部分介绍了IDE的选择、Clojure快速入门以及相关的数据库技术。

本书适合各个层次的读者。如果具备一些函数式方面的编程经验,将对阅读本书有所助益,但这并不是必需的。如果你还没有真的用过Clojure,可以快速掌握如何运用这门语言来解决Web开发中的实际问题。


这是一本极好的书,我会强烈推荐身边所有的Clojure Web开发者阅读。

Colin Yates,QFI咨询事务所首席工程师、技术团队领袖

Clojure这种语言棒极了,用它来开发Web应用简直是一种享受。对于诸多熟悉用Clojure Web开发工具库的人来说,这本书是份宝贵且及时的材料。

Fred Daoud,资深Web开发者、《七周七Web框架》合著者

对于想用Clojure开发Web应用的人来说,Dmitri Sotnikov会通过Clojure开发实践让你迅速上手。如果你已懂一些Clojure基本知识,但是还只在了解的阶段,那这本书太适合你了。

Russ Olsen,Cognitect副总裁、咨询服务

Sotnikov阐明了如何借助Clojure的灵活性来搭建网站,使用顶尖的库来制作务实的站点。

Chris Houser,《Clojure编程乐趣》合著者

有了这本书,您将手操强大的函数式编程技术直接步入Web开发。随着渐学渐长,你还会为你的应用注入更多可扩展、可维护的特性,并且,这种Clojure开发经验还会延续到JavaScript客户端。

Ian Dees,《Cucumber Recipes》作者

Dmitri在书中通过穿插介绍语言特性的同时,成功解决了现在软件开发中遇到的真实问题。这说明你投入时间阅读本书将富有效率和价值。

Brian Sletten,Bosatsu咨询、《Resource-Oriented Architecture Patterns for Webs of Data》作者

本书通篇详细介绍用Clojure搭建网站应用,节奏轻快、直白。从第1章开始,你将陆续接触真实的Web应用,接下来为其添加数据库、处理安全问题、JavaScript等。不教条、不唠叨、不废话!就为了简洁、高效。本书从无到有提供一个货真价实的应用,使得你可以通过完善不断成长。

Sam Griffith Jr.,语言专家、任职于Interactive Web Systems


这是一株盆景,优雅、高洁。之所以选择盆景作为本书的封面,是因为相同的品质在Clojure这门极具魅力的语言身上,同样展现得淋漓尽致。开发软件,就像是修剪一株盆景,只有精工细作,才能将其雕琢成型;也唯有工具趁手,方可体味其中的乐趣。而Clojure,就是这个不可思议的工具。相信在读完本书之后,你也会这么认为的。

本书适合各个层次的读者。如果具备一些函数式方面的编程经验,将对阅读本书有所助益,但这并不是必需的。如果你还没有真的用过Clojure,那么阅读本书会是一个不错的起点,因为本书关注的,就是如何运用这门语言来解决实际问题。这意味着,我们仅仅需要少量语言特性,就能实现常见的web应用。

作为一门轻巧的语言,Clojure的首要目标是简洁并且准确。此外,作为一门函数式语言,它还格外强调不变性(immutability),以及声明式编程(declarative programming)。正如你将在本书中看到的那样,这些特性使得编写清爽又正确的代码,竟会变得如此简单且自然。

编程语言各有千秋,关于它们之间孰优孰劣的争论也从未休止。有的语言,结构简单却表达冗长。也许你曾听人这样说过,表述啰唆一点并没什么大不了,理由是,只要两种语言都是图灵完备的,那么能用简洁语言表达出来的东西,用冗长一些的语言也可以表达出同样的含义,只不过是多出几行代码罢了。

然而,这种说法却忽略了关键所在。因为真正的问题并不在于能不能表达出来,而在于表达得好不好,直不直接。一门好的语言,能让你始终围绕着问题域去思考;而糟糕的语言,则迫使你不得不把问题转换为这门语言强加的概念。

后者,往往都是枯燥无味的。最终,你通篇都是样板代码,并且一再重复着这些早已做过无数遍的事情。如果我们总是不得不编写许多重复的代码,那多少也显得有些可悲了。

还有一些其他语言,它们并不冗长,甚至还提供了诸多用来解决各种问题的工具。可惜的是,绝大多数工具都未必能真正转化为更强的生产力。

语言具备的特性越多,你就越需要花费更多的精力来考虑如何有效地运用这门语言。我之前用过许多这样的语言,发现自己总会费尽心思地纠缠于众多特性之间,难以自拔。

于我而言,何谓理想的语言,就是我可以不假思索地使用它。当一门语言缺乏表达力,就会明显让我感觉到捉襟见肘。另一方面,当一门语言有太多特性的时候,我又会经常感到不知所措,甚至受其所扰。

用数学来进行类比,能记住一个可以推导其他公式的通用公式,总是好过死记硬背一大堆针对特定问题的公式。

Clojure正是为此而生。它使得我们借助少量的几个通用模式,就可以轻松获得解决特定问题的方案。你只需要学习几个简单的概念,以及些许语法,就可以迅速将它们转化为生产力。这些概念可以用无数种方法加以组合,用来解决任何类型的问题。

Clojure被广泛应用于各个领域,在其数以万计的使用者中,不乏银行和医院这样挑剔的用户。说Clojure是Lisp语系发展至今最流行的一门方言,也毫不为过了。尽管这门语言还很年轻,但它已经充分证明了自己。这份自信源自于它在生产系统中的表现,也源自于用户们排山倒海般的好评。

由于Web开发是Clojure的主战场之一,一些重要的库和框架也开始在这个领域崭露头角。一般来说,Clojure的Web栈是基于Ring[1]和Compojure[2]的,其中Ring是HTTP基础库,而Compojure则在Ring的基础上,提供了路由机制。在接下来的章节中,你将会逐渐地了解并熟悉这个Web栈,并懂得如何有效地借助它们来构建你自己的Web应用。

[1]https://github.com/ring-clojure/ring

[2]https://github.com/weavejester/compojure


在简介部分,我们谈到了在编写应用程序时,采用函数式编程风格能够获得诸多好处。当然,想要学会一门语言,仅仅通过阅读是远远不够的,只有亲手编写一些代码,你才能获得真切的体验。

在本章中,我们将会介绍如何开发一个简单的留言簿应用,用户可以使用它给他人留言。通过它,我们能够了解Web应用的基本结构,并且尝试一些高效的Clojure开发工具。如果你是一个Clojure新手,那我建议你先跳到“附录2 Clojure入门”,快速了解一下Clojure的基本概念和语法。

Clojure需要Java虚拟机(JVM,Java Virtual Machine)才能运行,此外,你还需要一份1.6或是更高版本的Java开发工具包[1](JDK,Java Development Kit)用于开发。Clojure是作为一个JAR包来分发的,你只需简单地将其包含在工程的class-path中即可。你可以使用任何常规的Java工具来构建Clojure应用,比方说Maven[2]或者Ant[3]。不过,我强烈建议你使用Leiningen[4],它是专为Clojure定制的。

借助Leiningen,你可以建立、构建、测试、打包和部署工程。也就是说,它能为你提供工程管理方面的一站式服务。

Maven是一个非常流行的Java依赖关系管理工具,而Leiningen就相当于Clojure世界中的Maven。重点是,Leiningen与Maven兼容,因此它可以毫无障碍地访问那些得到精心维护,且存放着海量Java类库的存储中心。此外,Clojure的库通常可以在Clojars[5]这个存储中心找到。所以,默认情况下Leiningen是启用了Clojars的。

使用Leiningen,你不用手动去下载那些在工程中需要用到的库。你只需要简单地声明一下工程的顶级依赖,剩下的事情Leiningen就会帮你自动搞定。

Leiningen的安装实在是小菜一碟,只需要从官方主页[6]上下载并执行安装脚本即可。

不如动手试试看。我们会通过执行下列命令,来下载这个脚本,并创建一个全新的Clojure工程:

wget https://raw.github.com/technomancy/leiningen/stable/bin/lein
chmod +x lein
mv lein ~/bin
lein new myapp

由于这是我们第一次运行lein这个命令,它做的第一件事情是安装它自己。一切顺利的话,你将会看到下面的输出:

Generating a project called myapp based on the 'default' template.
To see other templates (app, lein plug-in, etc), try `lein help new`.

一个新的文件夹myapp就创建好了,里面是应用程序的骨架。应用程序的代码存放在src文件夹中。其中有另外一个myapp文件夹,这个文件夹中只有一个文件,名为core.clj。文件内容如下:

(ns myapp.core)
(defn foo
  "I don't do a whole lot." 
  [x]
  (println x "Hello, World!"))

请注意命名空间的声明,与其文件夹结构是相匹配的。由于命名空间core位于myapp目录当中,所以它的名字就是myapp.core。

在工程文件夹myapp里有一个project.clj文件。这个文件包含了应用程序的描述信息,你可以仔细观察一下,就会发现这个文件是用标准的Clojure语法编写的,描述了应用的名称、版本、网址、许可证信息和依赖项,如下所示。

(defproject myapp "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:license {:name "Eclipse Public License"
     :url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.5.1"]])

通过修改这个project.clj文件,能让我们控制应用程序的方方面面。例如,我们可以通过添加:main关键字,将myapp.core命名空间下的foo函数设置为应用的入口点:

(defproject myapp "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:license {:name "Eclipse Public License"
     :url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.5.1"]]
;;this will set foo as the main function
:main myapp.core/foo)

此时我们就可以通过执行lein run这个命令来运行应用了。由于foo函数要求传入一个参数,我们只得遵命行事:

lein run First
First Hello, World!

在前面这个例子中,我们创建的应用非常简单,只有一个依赖项:Clojure运行时。如果我们直接以此为基础来开发Web应用的话,就免不了要编写大量的样板代码,才能让它运行起来。下面就让我们看看如何利用Leiningen的模板,来创建一个开箱即用的Web应用吧。

当把模板的名称提供给lein脚本时,就可以根据其对应的模板来初始化工程骨架。其实模板自身也不过是使用了lein-newnew插件[7]的Clojure工程罢了。稍后我们将看到如何创建自己的模板。

眼下,我们将会使用compojure-app模板[8]来初始化下一个应用。执行lein脚本时,模板的名称是作为参数传给new关键字的,紧接其后的是工程名称。为了创建一个Web应用,而不是之前那样的默认工程,我们只需执行以下命令即可:

lein new compojure-app guestbook

这样Leiningen就知道创建留言簿应用时,应该使用compojure-app模板了。此类应用需要启动一个Web服务才能运行。其实这很容易,我们只需要使用lein ring server来替代lein run即可。

当我们运行这个应用时,控制台会输出如下信息,与此同时还会弹出一个打开了应用主页的浏览器窗口。

lein ring server
guestbook is starting
2013-07-14 18:21:06.603:INFO:oejs.Server:jetty-7.6.1.v20120215
2013-07-14 18:21:06.639:INFO:oejs.AbstractConnector:
StartedSelectChannelConnector@0.0.0.0:3000
Started server on port 3000

喔,现在我们已经知道如何创建和运行应用了,接下来不妨考虑一下应该选用什么样的编辑器。

你多半已经留意到,Clojure代码中有大量的括号。保持它们起止对应很快就会成为一种挑战,所幸Clojure编辑器会替我们收拾这个摊子,否则会令人产生严重的挫败感。

事实上,这些编辑器不仅仅能平衡括号,其中的一些甚至能够感知其结构。这就意味着编辑器能够理解一个表达式是从什么地方开始,又到什么地方结束的。因此,我们可以根据逻辑上的代码块来导航和选取,而非简单针对文本行号。

在本章中,我们将会选用Light Table[9]来开发留言簿应用。获取并运行Light Table是非常容易的,这样我们就能尽快投入到代码的编写中了。然而,它的功能还比较有限,在较大的工程中,你对此可能有较深的体会。“附录1选择IDE”中还有对其他开发环境的讨论。

Light Table不需要安装,下载完成后即可直接运行。

Light Table的外观相当简洁。默认情况下,它仅在编辑器窗格中显示了几行欢迎信息,如图1-1所示。

图1-1 Light Table工作区

为了显示workspace面板,我们可以在菜单中选择View →Workspace,或是按下Ctrl+T(Windows/Linux)组合键或Cmd+T(OS X)组合键。

如图1-2所示,我们可以在workspace的folder标签页中打开留言簿工程。

图1-2 打开工程

一旦工程被选中,我们就可以浏览整个工程树,并选择我们想要编辑的文件,如图1-3所示。

现在,开发环境已经就绪,看起来我们终于可以为留言簿应用添加一些功能了。

图1-3 Light Table的工程

你的留言簿应该已经在控制台运行了,可以通过http://localhost:3000/来访问。在控制台终端按下Ctrl+C,就能停止它的运行。既然我们已经在Light Table的工作区打开了这个工程,不妨就直接在编辑器中运行它吧。

我们现在要更进一步,创建一个 “读取—求值—打印循环”(REPL,Read-Evaluate-Print Loop),将Light Table连接至我们的工程。菜单View →Connections可以打开连接标签页。如图1-4所示,让我们点击标签页中的Add Connection按钮。

图1-4 Light Table的连接

此时,会弹出一个列表,列出了几种不同的连接选项。如图1-5所示,接下来选择Clojure。然后,让我们找到留言簿工程所在的文件夹,并且选中project.clj文件。

图1-5 Light Table连接Clojure

一旦我们的工程与Light Table建立了连接,我们就可以直接在编辑器中对代码进行求值了。

说不如做,你可以立刻挑选一个函数,然后按下Ctrl+Enter(Windows/Linux)组合键或是Cmd+Enter(OS X)组合键。如果我们选择的是home函数,那么打印出来的内容应该是这样:

#'guestbook.routes.home/home

这意味着这个函数已经在REPL中进行了求值,随时可用了。

另外,按下Ctrl+spacebar组合键后输入repl,就能打开一个即时repl。在这个新打开的编辑器窗格中,我们可以随意运行任何代码,如图1-6所示。

图1-6 Light Table的即时repl

默认情况下,一旦进行任何修改,都会使得即时repl中的所有内容被重新求值。这被称为live实时模式。现在,让我们载入guestbook.repl命名空间,然后执行start-server函数。

(use 'guestbook.repl)
(start-server)

一旦上述代码完成求值,就会启动HTTP服务,同时打开一个新的浏览器窗口,指向了应用的主页,如图1-7所示。

图1-7 在即时repl中运行服务

显然我们不希望start-server被反复调用,因此记得从即时repl里删除之前的代码。

另外,我们还可以关闭实时求值功能,只要点击右上角的live图标即可。禁用了实时模式后,我们可以通过Alt-Enter来进行选择性的求值。

下面,如图1-8所示,让我们执行(use 'guestbook.routes.home)来导入home命名空间,然后调用home函数。

如你所见,对home的调用只是简单生成了我们的HTML主页,一个字符串。这就是我们访问http://localhost:3000时,浏览器为我们呈现出来的内容。

图1-8 使用REPL

值得注意的是,在我们的代码中使用了Clojure的vector(矢量表)来表达相应的HTML标签。如果我们添加一些新的标签,并在浏览器中刷新页面的话,立刻就能看到变化。例如,让我们对home函数稍事修改,让它能够显示标题,并提供一个用于录入消息的表单。

(defn home []
 (layout/common
 [:h1 "Guestbook"]
 [:p "Welcome to my guestbook"]
 [:hr]
 [:form
  [:p "Name:"]
  [:input]
  [:p "Message:"]
  [:textarea {:rows 10 :cols 40}]]))

好了,刷新一下页面,看到变化了吧,如图1-9所示。

图1-9 留言簿

你可能已经猜到了,紧接着home函数的那几行代码,就是负责将“/”路由和处理函数home绑到一块儿的。

(defroutes home-routes
 (GET "/" [] (home)))

此处,我们使用defroutes来定义guestbook.routes.home命名空间中的路由。每个路由都代表着一个应用会响应的URI地址。路由定义的起始位置是HTTP请求的类型,例如,GET或者POST,接下来则是参数和主体部分。

我们还会为这个工程添加更多的功能,在此之前,让我们了解一下Leiningen模板为我们生成了哪些文件吧。

在Workspace标签页中展开我们的工程之后,看上去应该是这样的:

guestbook/
 resources/
  public/
   css/
    screen.css
   img/
   js/
 src
  guestbook/
  models/
  routes/
   home.clj
  views/
   layout.clj
  handler.clj
  repl.clj
test/
 guestbook/
 test/
   hanlder.clj
project.clj
README.md

位于工程根目录下的project.clj文件是用于配置和构建应用的。

还有几个文件夹,src用来存放应用的代码。resources文件夹则用来存放与应用相关的静态资源,比如CSS、图片和JavaScript脚本。最后,在test文件夹中,我们可以为应用添加一些测试。

Clojure命名空间遵循Java的打包约定,也就是说,如果命名空间包含前缀,则其存放的文件夹路径必须与前缀相匹配。需要注意的是,如果一个命名空间包含“-”,则体现在文件夹路径和文件名上时,“-”必须转换为“_”。

这是因为Java的包名中不允许出现“-”。而Clojure代码最终会被编译为JVM字节码,所以也必须遵守这个规则。

由于我们把自己的应用叫作guestbook,因此它所有的命名空间都被放置在了src/guestbook文件夹下。让我们看看都有些什么吧。首先,我们在src/guestbook/handler.clj文件中找到了guestbook.handler命名空间。这个命名空间包含了应用程序的入口点,此外还定义了被用来处理所有请求的handler。

在src/guestbook/repl.clj文件中的是guestbook.repl命名空间,调用里面的函数,就可以在REPL中启动和停止服务。我们可以借助它直接从编辑器中启动我们的应用,而不必非得通过lein来运行。

接下来,我们有一个名为models的文件夹。这是留给应用的模型层的。里面的命名空间也负责连接数据库、定义表结构,还有访问记录等。

在routes文件夹下,是那些负责定义路由的命名空间。这些路由构成了我们将要实现的工作流的入口点。

目前,我们只有一个被称为guestbook.routes.home的命名空间,应用的主页就是在这里定义的。这个命名空间位于src/guestbook/routes/home.clj文件中。

接下来的文件夹是views,里面的命名空间通常负责应用的界面布局。其自带的命名空间guestbook.views.layout定义了页面的基本结构。显而易见,这个命名空间对应的文件就是src/guestbook/views/layout.clj。

让我们来看看如何为留言簿应用创建用户界面(UI,user interface)吧。即使你阅读这些代码会感觉有点吃力,也不必担心,在后面的章节中你还有机会弄明白。相比纠缠于每个函数的细枝末节,目前应把注意力放在如何组织我们的应用,以及如何拆分应用逻辑更为重要。

在前面,我们曾经用纯手工的方式创建了一个录入表单。现在,我们打算用一个更好的实现来替代它,这会用到Hiccup[10]库提供的辅助函数。

为了使用这些函数,需要把库导入我们的命名空间,像下面这样修改命名空间的声明:

(ns guestbook.routes.home
  (:require [compojure.core :refer :all]
       [guestbook.views.layout :as layout]
       [hiccup.form :refer :all]))

首先我们创建一个函数,用来呈现已有的消息。这个函数会生成一个包含了现有消息的HTML列表。就目前来说,我们先简单地硬编码几条消息就行。

(defn show-guests []
 [:ul.guests
  (for [{:keys [message name timestamp]}
     [{:message "Howdy" :name "Bob" :timestamp nil}
      {:message "Hello" :name "Bob" :timestamp nil}]]
  [:li
    [:blockquote message]
    [:p "-" [:cite name]]
    [:time timestamp]])])

接下来,我们对home函数进行调整,使顾客可以看到前面那些顾客留下的消息。当然,还得提供一个表单用来创建新的消息。

(defn home [& [name message error]]
 (layout/common
  [:h1 "Guestbook"]
  [:p "Welcome to my guestbook"]
  [:p error]
  ;here we call our show-guests function   ;to generate the list of existing comments   (show-guests)
  [:hr]
  ;here we create a form with text fields called "name" and "message"   ;these will be sent when the form posts to the server as keywords of   ;the same name   (form-to [:post "/"]
   [:p "Name:"]
   (text-field "name" name)
   [:p "Message:"]
   (text-area {:rows 10 :cols 40} "message" message)
   [:br]
   (submit-button "comment"))))

切换到浏览器,可以看到两条测试消息连同表单一块儿都显示出来了。请留意,现在home函数多了几个可选参数。我们会把这些参数的值显示到页面上。如果这些参数为nil,那么在进行显示时,会把它们视作空字符串。

我们创建的这个表单会向“/”发送HTTP的POST请求,所以我们再添加一个路由来处理它吧:这个路由将会调用一个名为save-message的辅助函数,我们稍后会给出其定义。

guestbook/src/guestbook/routes/home.clj
(defroutes home-routes
(GET "/" [] (home))
(POST "/" [name message] (save-message name message)))

save-message函数会检查name和message这两个参数,然后就去调用home函数。倘若两个参数都没问题,那么消息会被打印到控制台;否则,将会生成一条出错信息。

(defn save-message [name message]
 (cond
  (empty? name)
  (home name message "Some dummy forgot to leave a name")
  (empty? message)
  (home name message "Don't you have something to say?")
  :else
  (do
   (println name message)
   (home))))

来,在留言簿中留一次言试试看,你会看到名字和消息在控制台里打印出来了。接下来,将name或者message留白,看看有没有显示出错消息。

现在,视图部分已经具备了通过UI显示和提交消息的能力。但此时此刻,我们还没有能真正存放这些消息的地方。

既然我们的应用需要保存访客们的留言,那我们在project.clj[11]文件中加入对JDBC和SQLite的依赖项吧。添加完毕后的,:dependencies看起来应该是下面这样子的:

:dependencies [[org.clojure/clojure "1.5.1"]
         [compojure "1.1.5"]
         [hiccup "1.0.4"]
         [ring-server "0.3.0"]
         ;;JDBC dependencies          [org.clojure/java.jdbc "0.2.3"]
         [org.xerial/sqlite-jdbc "3.7.2"]]

因为添加了新的依赖项,我们需要将工程与REPL重新连接。首先打开Connect标签页并且点击disconnect按钮,然后按照先前介绍过的步骤来连接一个新的REPL实例,如图1-10所示。

图1-10 断开REPL

一旦重新连上了REPL,我们就需要在即时repl中执行(start-server),早些时候我们曾经做过一次,还记得吗?

OK,万事俱备,只欠数据模型了。我们会在src/guestbook/models文件夹下创建一个新的命名空间。我们把这个命名空间称为guestbook.models.db。具体做法是:在工作区中,右键单击models文件夹,并且选择New File选项,然后将这个文件命名为db.clj。

正如其名称所暗示的,db命名空间将负责应用的数据模型,并且提供从数据库读取或是向数据库写入数据的功能。

首先,我们需要添加命名空间声明,以及导入数据库依赖项。下面是这个命名空间的声明:

guestbook/src/guestbook/models/db.clj
(ns guestbook.models.db
 (:require [clojure.java.jdbc :as sql])
 (:import java.sql.DriverManager))

请注意,导入其他Clojure命名空间时,我们使用的是:require关键字,而导入Java类时,我们则用了:import。

下一步,我们将要创建数据库连接的定义。这个定义其实就是一个简单的map,包含了JDBC驱动的类型、协议,以及SQLite数据库的文件名。

guestbook/src/guestbook/models/db.clj
(def db {:classname "org.sqlite.JDBC",
     :subprotocol  "sqlite",
     :subname     "db.sq3"})

声明了数据库连接之后,我们还需要编写一个函数,创建用于保存访客留言的数据表。

guestbook/src/guestbook/models/db.clj
(defn create-guestbook-table []
 (sql/with-connection
  db
  (sql/create-table
   :guestbook
   [:id "INTEGER PRIMARY KEY AUTOINCREMENT"]
   [:timestamp "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"]
   [:name "TEXT"]
   [:message "TEXT"])
  (sql/do-commands "CREATE INDEX timestamp_index ON guestbook (timestamp)")))

这个函数使用了with-connection语句,这样就能确保数据库连接在使用完毕后能够得到恰当的清理。在其内部,我们调用create-table函数来创建数据表,表名用关键字表示,而表示字段则使用了vector。为了完整起见,我们还为timestamp字段创建了索引。

在即时repl中执行(create-guestbook-table)之前,我们首先要导入它的命名空间,前面我们曾经对guestbook.routes.home也这么做过,还记得吗?

(use 'guestbook.models.db)
(create-guestbook-table)

现在你就可以在即时repl中执行create-guestbook-table,把数据表给创建出来了。但有一点需要注意,如果你开启了实时模式,那么最好将其禁用;否则每次即时repl临时缓冲的变动,都会导致create-guestbook-table被调用并产生错误。

数据表创建完毕,接下来我们就可以编写从数据库中读取留言的函数了。

guestbook/src/guestbook/models/db.clj
(defn read-guests []
 (sql/with-connection
  db
  (sql/with-query-results res
    ["SELECT * FROM guestbook ORDER BY timestamp DESC"]
    (doall res))))

此处我们使用with-query-results与来执行select语句,并返回其结果。之所以要在返回之前调用doall,是因为res是惰性的,不会把所有结果都加载到内存中。

通过调用doall,我们强制对res进行了完全求值。如果不这么做的话,一旦离开了函数的作用范围,我们的数据库连接就会被关闭,于是便无法在函数之外访问结果数据了。

我们还需要创建另外一个函数,用来把消息保存到留言簿的数据表中。这个函数会调用insert-values,并且接受访客的名字和消息作为参数进行保存。

guestbook/src/guestbook/models/db.clj
(defn save-message [name message]
 (sql/with-connection
  db
  (sql/insert-values
    :guestbook
    [:name :message :timestamp]
    [name message (new java.util.Date)])))

用于读取和保存消息的函数已经写好,现在我们可以在REPL中尝试一下了。我们需要在即时repl中重新执行一遍(use 'guestbook.models.db),这样才能访问这几个新添加的函数。然而,在guestbook.models.db和guestbook.routes.home这两个命名空间中都定义了名为save-message的函数。

如果尝试重新加载guestbook.models.db命名空间,我们会得到一个错误,指出save-message已经从guestbook.routes.home命名空间导入过了。为了避免这个问题,在执行(use 'guestbook.models.db)之前,我们需要在即时repl中先执行ns-unmap,移除当前对save-message的引用。

(ns-unmap 'user 'save-message)
(use 'guestbook.models.db)

现在我们可以尝试运行下面的代码,看看保存和读取消息的逻辑是否符合预期:

(save-message "Bob" "hello")
(read-guests)

将留言保存到数据库,然后读取出来以后,我们应该能看到图1-11所示的输出。

有了持久层,我们就可以回过头去修改home命名空间,将先前那些硬编码的假数据统统扔掉了。

现在我们可以把对db的依赖项添加到home路由的命名空间声明中了。

guestbook/src/guestbook/routes/home.clj
(ns guestbook.routes.home
 (:require [compojure.core :refer :all]
      [guestbook.views.layout :as layout]
      [hiccup.form :refer :all]
      [guestbook.models.db :as db]))

接下来,我们需要修改show-guests函数,让它去调用db/read-guests:

图1-11 测试保存功能

(defn show-guests []
 [:ul.guests
  (for [{:keys [message name timestamp]} (db/read-guests)]
  [:li
    [:blockquote message]
    [:p "-" [:cite name]]
    [:time timestamp]])])

最后,我们还得修改save-message函数,让它调用db/save-message,而不是简单地把参数打印出来:

guestbook/src/guestbook/routes/home.clj
(defn save-message [name message]
 (cond
  (empty? name)
  (home name message "Some dummy forgot to leave a name")
  (empty? message)
  (home name message "Don't you have something to say?")
  :else
  (do
   (db/save-message name message)
    (home))))

完成这些修改之后,我们都迫不及待地要打开浏览器中看看效果如何啦。不出所料,先前我们在REPL中添加到数据库中的那条消息显示出来了,如图1-12所示。

图1-12 真正的留言

我们还可以试着多录入几条消息,以确认留言簿的功能确实符合预期。

你也许注意到了,页面上消息的显示是存在缺陷的。时间只是简单的显示为毫秒数。这实在是太不友好了,所以,让我们添加一个改善其格式的函数吧。

为此,我们将会创建一个Java的SimpleDateFormat对象,用来对时间戳进行格式化。

guestbook/src/guestbook/routes/home.clj
(defn format-time [timestamp]
 (-> "dd/MM/yyyy"
    (java.text.SimpleDateFormat.)
    (.format timestamp)))
(defn show-guests []
 [:ul.guests
  (for [{:keys [message name timestamp]} (db/read-guests)]
   [:li
     [:blockquote message]
     [:p "-" [:cite name]]
     [:time (format-time timestamp)]])])

我们的留言簿应用已接近完成,还剩下最后一个问题。

由于我们需要先创建数据库,其后才能访问,所以还需要往handler命名空间中添加一些代码。首先,我们需要在handler中导入命名空间db。

(ns guestbook.handler
 ...
 (:require ...
       [guestbook.models.db :as db]))

接下来修改init函数,检查数据库是否存在,如果不存在则创建之。

guestbook/src/guestbook/handler.clj
(defn init []
 (println "guestbook is starting")
 (if-not (.exists (java.io.File. "./db.sq3"))
  (db/create-guestbook-table)))

由于应用加载时会调用init函数,因此就能确保数据库在真正开始运行之前便已经准备妥当了。

通过前面这个例子,我们体验了如何使用Clojure来开发Web应用。你也许已经注意到了,你只是编写了极少的代码,就得到了一个可用的程序。而且,你几乎没有编写任何样板代码。

阅读至此,你对程序结构、主要组件,以及如何将它们组合到一起应该相当熟悉了。

回顾一下,我们的应用包含了以下几个命名空间。

命名空间guestbook.handler的职责是启动服务,并创建一个handler,负责把来自客户端的请求传递给应用。

然后是命名空间guestbook.routes.home。我们在这里建立了留言功能的工作流程,同时大部分应用逻辑也都位于此处。如果需要添加更多的工作流,你需要在guestbook.routes下创建新的命名空间。例如,你可能会创建guestbook.routes.auth命名空间,用来处理用户注册和认证。

通常,routes文件夹下的每个命名空间都封装着应用中一个自包含的工作流程。所有与之相关的代码都位于同一个地方,并且与其他的路由保持独立。此处工作流表示的可能是用户认证,也可能是内容编辑,或是事务管理等。

命名空间guestbook.views.layout负责管理应用的界面布局。我们会在这里放置一些代码,用来生成页面的公共元素,以及控制页面的结构。一般来说,布局方面需要考虑的内容包括:组织静态资源,比如页面需要的CSS和JavaScript文件;设置公共元素,比如页眉和页脚等。

最后,还有命名空间guestbook.models.db,它负责整个应用的数据模型。联系例子中定义的数据表,它描述了数据的类型,以及哪些用户的数据需要持久化。

当我们着手构建更大规模的应用时,这些东西应该牢记于胸。一个结构良好的Clojure应用会易于理解,也方便维护。对于有些编程语言,当代码规模较大时,你得费尽心思才能理清其复杂的层次结构。而在Clojure应用的整个生命周期中,你都不会有类似的烦恼,这真是太美妙了。

我们使用了Light Table来开发留言簿应用。虽然它相当易用,但仍需更多打磨,还缺乏一些其他集成开发环境(IDE,Integrated Development Environments)提供的有用特性。这些特性包括代码完成、结构化的代码编辑,以及集成的依赖管理。

所以,我建议你花些时间去尝试一下那些更为成熟的开发环境,例如Eclipse[12]或者Emacs[13]。本书的剩余部分假定以Eclipse作为我们的开发环境,不过,无论你选用的是哪种编辑器,都没有任何问题。如需了解其他可选的IDE,不妨参考“附录1 选择IDE”。

你会发现,在开发应用的过程中,我们大量使用了REPL。因此,对于Clojure开发环境而言,是否集成了REPL可谓有着天壤之别。能在REPL中执行代码,就意味着你能获得更快的反馈周期,从而显著地提升生产力。

在本章中,我们演示了如何设置开发环境,以及如何搭建一个典型的Clojure Web应用。下一章,我们将关注那些构成Clojure Web栈的核心库。你将会了解到以下内容:请求响应的生命周期、定义路由、会话管理,以及利用中间件强化核心处理请求功能。

[1]http://www.oracle.com/technetwork/java/javase/downloads/index.html

[2]http://maven.apache.org/

[3]http://ant.apache.org/

[4]http://leiningen.org/

[5]https://clojars.org/

[6]http://leiningen.org/#install

[7]https://github.com/Raynes/lein-newnew

[8]https://github.com/yogthos/compojure-template

[9]http://www.lighttable.com/

[10]https://github.com/weavejester/hiccup

[11]http://www.sqlite.org/

[12]http://www.eclipse.org/

[13]http://www.gnu.org/software/emacs/


在上一章,我们直接构建了一个简单的应用。通过它,我们对工程的结构有了初步印象,同时也熟悉了开发环境。现在,我们将节奏放缓,先往后退一步,了解一下所有这些组件的运作细节。

Clojure社区崇尚简单和灵活,而不是循规蹈矩或是一成不变。实际上,Web栈中的所有组件,都有为数众多的替代品。你可以根据自己的风格,以及你开发的应用类型做出选择。本书中,我们把重点放在流行的Ring/Compojure栈,现实当中,许多案例都是用它创建的。

前面的章节中我们介绍了一个简单的应用,用户可以留言并且能够看到其他用户的留言。我们介绍了工程的目录结构和主要文件,以及它们的用途。然而,我们还没有真正关注这些文件中的代码。在本章中,你将学习一些必要的背景知识,以便能够充分地理解我们的这个留言板应用。

由于Clojure Web栈是建立在Java HTTP Servlet API[1]之上的,所以可以将应用部署到任意的servlet容器中,比如Jetty[2]、GlassFish[3]或者是Tomcat[4]

你可以选择让Clojure应用独立运行,也可以将它和其他Java应用一块儿部署在一个应用服务器上。

由于众多云服务都支持Java虚拟机,你也可以考虑把应用部署到云端。这些服务包括亚马逊的AWS[5]、谷歌的App Engine[6],此外还有Heroku[7]和Jelastic[8]等。

servlets用于处理任意的网络请求/相应交换,这意味着HTTP servlets会按照RFC描述的有关内容处理HTTP交换。servlet容器调用servlet类的接口并传入对应的数据作为请求,并且servlet返回用于servlet容器的数据作为响应。这套API提供了诸多Web开发中需要用到的核心功能,比如cookies、会话,以及URL重写。然而,servlets是专为Java语言设计的,如果直接在Clojure中使用,未免有些别扭。

不像许多其他平台(比如Rails或Django),Clojure的Web栈并没有提供那种自以为完备的整体框架。相反,你可以把各种库糅合在一起,来构建你自己的应用。本书中,我们仅专注几个常用的Web开发库。

作为起点,让我们先了解一下Ring和Compojure这两个提供了原生Clojure Servlet API的库吧。Ring封装了Java的servlet API,而Compojure则用来把请求处理函数映射到指定的URL。应用本身则位于栈顶,使用这些库来与客户端交互,以及管理应用的状态。

Ring的目标是把HTTP的细节抽象为简单且模块化的API,可以用来构建类型广泛的应用。如果你曾经使用Python或是Ruby开发过Web应用的话,那么你会发现Ring与Python的WSGI[9]和Ruby的Rake[10]非常类似。

对于构建Web应用,Ring已经成为了事实上的标准,因此诞生了很多周边的工具和中间件。尽管在大多数情况下你都无需直接与Ring打交道,但高屋建瓴地了解一下其设计,将对后续的开发和排错有颇多益处。

基于Ring的应用都包含以下这四个基本组件:处理器(handler)、请求(request)、响应(response)和中间件(middleware)。来分别了解它们一下吧。

Ring使用标准的Clojuremap来表示客户端请求以及服务端响应。而所谓handler,不过是一组用于处理客户端请求的函数罢了。这些函数的参数是请求map,返回值则是响应map。下面是一个非常简单的Ring handler:

(defn handler [request-map]
 {:status 200

  :headers {"Content-Type" "text/html"}
  :body (str "<html><body> your IP is: "
        (:remote-addr request-map)
        "</body></html>")})

如你所见,它的参数是一个表示HTTP请求的map,返回了一个表示HTTP响应的map。至于说如何将HTTP servlet请求对象转换为map,以及如何将map转换为响应对象,那就是Ring操心的问题了。

前面的这个handler只是简单地生成了一段内容为客户端IP地址的HTML字符串,并将响应的状态码置为200。由于类似这种操作实在是太常见了,于是Ring就提供了一个辅助函数用来生成这样的响应:

(defn handler [request-map]
 (response

  (str "<html><body> your IP is: "
     (:remote-addr request-map)
     "</body></html>")))

如果想要创建自己的响应,你只用编写一个函数,处理传入的请求map,并返回用于表示你自己响应的map即可。下面让我们来了解一下这两个map的格式吧。

请求map和响应map都包含了诸如服务端口、URI、对端地址、负载类型以及实际的负载数据。这些map的键名源自于servlet API和官方的HTTP RFC标准文档[11]

请求map中定义了下列的标准键。注意,此处列出的键,并不一定会出现在所有的请求中,比如:ssl-client-cert。

除了上述由Ring规定的标准键之外,请求map中还有可能会出现由中间件函数添加的其他一些特定于应用的键。怎么才能做到?别着急,本章后面会讨论这个话题。

响应map仅包含三个键,就足以描述HTTP响应了:

status是一个数字,表示HTTP RFC标准中定义的一个状态码,规定其最小值为100。

headers是一个map,包含所有表示HTTP头部的键值对。头部可以是字符串,也可以是字符串的序列,在这种情况下,序列中的每个字符串都会作为单独的键和值来发送。

最后,响应消息体可以是一个字符串、一个序列、一个文件或者是一个输入流。此外,消息体还应该与响应的状态码对应。

当响应的消息体是一个字符串时,它会被原样发送给客户端。而如果它是一个序列的话,那么发送给客户端的将是每一个元素的字符串表达。最后,如果响应是一个文件,或者是一个输入流,那么服务器会将其中的内容发送给客户端。

所谓中间件,就是一些用来封装处理器(handler)的函数,这些函数能够更改处理请求的方式。中间件函数通常被用于扩展Ring的基本功能,以满足应用的实际需要。

中间件本身就是一个函数,它接受一个现有的handler和一些其他的可选参数,并返回一个新的handler,只不过这个新的handler将具有一些新的行为特征。下例就是这样的一个函数:

(defn handler [request]
 (response
  (str "<html><body> your IP is: "
     (:remote-addr request)
     "</body></html>")))

(defn wrap-nocache [handler]
 (fn [request]
   (let [response (handler request)]
     (assoc-in response [:headers "Pragma"] "no-cache"))))

(def app (wrap-nocache handler))

以上例子里封装了一个函数,它接受一个handler,并返回一个handler形式的函数。由于这个返回函数封装在局部,也就可以在处理内部引用handler。当函数被调用,它就将请求参数传递给handler并在回应的map里添加Pragma:no-cache。

这种封装处理称为闭包(closure),因为它隐蔽了handler函数的参数内容,使之易于处理返回。

面对程序中的任何具体问题,我们都可以用刚才这种手法创建小函数(small functions)来解决。再将他们通过各种组合,使应用程序可以轻松应对任何复杂的真实环境。

适配器位于handler和HTTP框架协议之间,它们组织并提供一些必要的内容,比如端口映射、解析HTTP请求,还能通过handler返回的map构造HTTP响应。不过,你并不太需要直接和适配器打交道,我们就此不提了。

Compojure是构建在Ring之上的路由库,它提供的方式非常简洁,用来关联处理URL和HTTP方法。Compojure路由基本上是这样子的:

(GET "/:id" [id] (str "<p>the id is: " id "</p>" ))

其路由函数名与HTTP方法名直接对应,比如GET、POST、PUT、DELETE和HEAD。还有一个称为ANY的路由会响应客户端任何方法。URI是包含冒号的键名,对应的那些值可以用作路由参数,Rails[12]和Sinatra[13]就是使用类似的处理机制,而Compojure正是受到这种特性的启发。上面的Ring回应描述中会自动包含路由回应。

其实在我们的实际应用中,可能会存在多条路由,Compojure提供了路由功能,能从多条路由中创建一个Ring处理。假设我们有/foo路由和/:id项,那么我们可以使用单条处理进行如下合并:

(defn foo-handler []
 "foo called")

(defn bar-handler [id]
 (str "bar called, id is: " id))

(def handler
 (routes
  (GET "/foo"[](foo-handler))
   (GET "/bar/:id foo" [id] (bar-handler id))))

定义路由是一种很常见的操作,Compojure还提供了defroutes宏,通过给定的路由生成一个Ring处理程序:

(defroutes handler
 (GET "/foo" []    (foo-handler))
 (GET "/bar/:id" [id] (bar-handler id)))

使用Compojure路由,可以非常方便地将网站的每个URL映射到功能代码,并且提供Web应用的大部分核心功能。我们可以像前面那样,使用defroutes宏把这些路由组织起来。大致上,Compojure就是这样维护Ring处理的。

对基于路径共享的程序,Compojure也提供强大的过滤机制处理常见路由。假设我们现有多条路由来响应特定用户:

(defn display-profile [id]
 ;;TODO: display user profile  )
(defn display-settings [id]
 ;;TODO: display user account settings  )
(defn change-password [id]
 ;;TODO: display the page for setting a new password  )
(defroutes user-routes
 (GET "/user/:id/profile" [id] (display-profile id))
 (GET "/user/:id/settings" [id] (display-settings id))
 (GET "/user/:id/change-password" [id] (change-password-page id))

现在每条路由前段都是/user/:id,必然会有很多重复代码。我们可以使用context宏,来解析路由的相同部分。

(def user-routes
   (context "/user/:id" [id]
     (GET "/profile" [] (display-profile id))
     (GET "/settings" [] (display-settings id))
     (GET "/change-password" [] (change-password-page id))))

这段代码中,路由定义了与/user/:id有关的内容,和前一个版本功能完全一样,都能使用id参数。context宏正是通过闭包来实现的。输出handler封装了通用参数,它们就可以在内部定义。

有些路由,需要我们使用请求的map保存请求参数。我们通过以下这种方式声明map,并作为路由的第二参数:

(GET "/foo" request (interpose ", " (keys request)))

此路由提取请求map的所有键名,并罗列出来,其输出如下。

:ssl-client-cert, :remote-addr, :scheme, :query-params, :session, :form-params,
:multipart-params, :request-method, :query-string, :route-params, :content-type,
:cookies, :uri, :server-name, :params, :headers, :content-length, :server-port,
:character-encoding, :body, :flash

Compojure同样提供一些实用功能来处理请求map,包括格式化参数之类。例如,在留言簿程序中(第1章“起步”,第1页),我们看到如下路由定义:

(POST "/" [name message] (save-message name message))

这个路由从请求参数中提取了:name 和:message两个键,然后将它们绑定给同名变量。就像其他的声明变量一样,现在,我们在路由作用范围内就可以使用了。

常规的Clojure解构也可用于路由内部,假设给定一个包含如下参数的请求map:

{:params {"name" "some value"}}

我们可以使用这种方式从参数中提取“name”关键字:

(GET "/:foo" {{value "name"} :params}
 (str "The value of name is " value))

此外,Compojure 还提供解构形参子集,并用剩余部分创建一个map:

[x y & z]
x -> "foo"
y -> "bar"
z -> {:v "baz", :w "qux"}

以上代码中,参数x和y都绑定到变量,v和w被重命名为一个名为z的map。此外,如果我们需要完整的请求参数,我们还可以进行如下处理:

(GET "/" [x y :as r] (str x y r))

这里,我们将形参绑定给x、y,还有完整的请求map绑定给变量r。Ring和Compojure装备上函数式这种强劲的武器,我们就能轻易创建页面,并为站点提供路由。但是,完善的应用还需要许多其他的特性,比如页面缓存、会话管理、输入验证,面对这些任务,我们使用最棒的适配器库来逐个击破。

典型的Compojure开发Web程序方式可能不同于你之前使用的方式。多数框架偏好使用模型-视图-控制器(MVC,model-view-controller)模式使用逻辑分离思想将视图、控制、模式严格分开。这里,Compojure并没有明确分离视图和控制。

相反,我们为程序中每个路由创建了独立的handler,这些handler用于处理来自客户端的HTTP请求,Compojure正是以这种思路来分派任务的。handler驱动模型负责处理域逻辑。这种方法提供了一个彻底的域逻辑分离模式,并不牵涉应用程序的表示层,也没有任何不必要的联系。

尽管如此,Clojure的Web栈设计得还是比较灵活,它甚至允许你以任何喜好的方式来组织,如果你非要在程序中使用传统MVC风格,也不会有什么麻烦。

仅通过几个逻辑部件就能一览典型应用(这是指我们前面做的那个留言簿程序的结构)。那我们再看看别的一些特性,多数应用被拆分为如下几个方面。

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函数时,程序将开始处理所有路由请求。

(def app (handler/site (routes home-routes app-routes)))

这段代码,compojure.handler/site函数用于生成Ring handler,用中间件支撑一个典型网站。

site函数仅仅创建一个handler,并将其封装进一些通用中间件,来支持通用网站。中间件由如下封装器构成。

在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))
 (POST "/" [name message] (save-message name message)))

第一条路由被绑定于/,用于从数据库检索消息,并用此消息创建一张表单,最终呈现整幅页面给客户端。

第二条路由会处理用户输入。如果输入验证通过,接下来这条消息就会被存入数据库;否则,页面将呈现错误描述。

其实这两条路由功能有交集:存储和显示用户信息,它们也算是同一工作流的两个部分。

当你发现程序的工作流有明确所属,那么可以将此工作流的逻辑关系合并,放在一起处理。程序中的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并不关心我们使用的具体方法,这恰好为选择模板库留有余地。可选的方案不少,这里介绍几个流行的库:Hiccup[14]、Enlive[15]、Selmer[16]、Stencil[17]

Hiccup能使用原生Clojure数据结构,通过它定义表情并生成相适应的HTML;Enlive反其道而行,使用纯HTML定义页面而不用特殊处理标签。适配器将特定模型和域变换为HTML模板。

与Hiccup和Enlive不一样,Stencil和Selmer都是基于外部模板系统,而不是基于Clojure。Stencil是实现了Mustache(这是个流行的无逻辑模板系统),Selmer是模仿Django模板系统在Python上的实现。

本书重点关注并使用Hiccup,因为它不需要额外学习任何语法,直接使用Clojure函数即可。此外,我们在后面还会学习用Selmer模板来取代Hiccup创建的应用。

别的选择彻底没有考虑使用服务端模板,你需要在客户端处理模板来接管这些工作,挑个流行的JavaScript库,并使用Ajax与服务通讯。当然,这样也能胜任。好处是这可以让客户端服务端的界限明确、清晰,有助于扩充其他形式的客户端,比如移动应用接口。在编写单页应用[18]时,这还是通行手段。

无论你喜欢何种模板策略,最佳实践都不会去聚合域逻辑和视图。通过合理构架的程序,是可以轻松替换模板引擎的。

现在开始介绍一些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</tag-name>

如果我们想要创建一个包含图片的div标签,可以创建一个vector,第一个元素为:div关键字,紧随其后是一个map(包含div ID和div的class)。余下部分是以vector表示图片的内容构成。

[:div {:id "hello", :class "content"} [:p "Hello world!"]]

我们使用hiccup.core/html宏将vector转换为HTML文本:

(html [:div {:id "hello", :class "content"} [:p "Hello world!"]])
<div id="hello" class="content"><p>Hello world!</p></div>

由于Hiccup允许你通过map设置元素属性,如有必要,你还可以使用元素内联样式。尽管如此,你还是应该抵御这种诱惑,使用CSS样式化元素取代之,这可以确保结构和描述分离。

由于对元素设置ID和设置class是常用操作,Hiccup还提供便捷的CSS样式化处理。我们可以如下简化编写我们的div,取代之前的代码:

[:div#hello.content [:p "Hello world!"]]

Hiccup同样提供一些辅助函数,用来定义常用元素,比如表单、链接、图像。所有这些函数输出的vector,由Hiccup预先定义的格式描述。

当一个函数在使用中并不能满足需求时,你当然可以写下元素的文本描述,还可以调整输出来满足需要。描述HTML元素的函数可以配置,其第一个参数可以接受可选属性的map。我们再了解一些常用的Hiccup辅助函数,来改善使用体验。

首先,我们来看看怎么用link-to辅助函数创建一个标签:

(link-to {:align "left"} "http://google.com" "google")

这段代码将生成以下vector:

[:a {:align "left", :href #<URI http://google.com>} ("google")]

我们已有一个关键字:a作为第一项,紧随其后的map表示属性,以及表示内容的list。

还是如此,将link-to函数封装在html宏里面,我们可以基于此vector输出HTML:

(html (link-to {:align "left"} "http://google.com" "google"))
<a align="left" 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:

<form action="/" method="POST">
 <p>Name:<input id="name" name="name" type="text" /></p>
 <p>Message:<textarea cols="40" id="message" name="message" rows="10">
 </textarea></p><input type="submit" value="comment" />
</form>

还有一个实用的辅助宏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]
 (html5
  [:head
  [: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 #<URI /css/reset.css>, :rel "stylesheet"}]
 [:link
 {:type "text/css", :href #<URI /css/screen.css>, :rel "stylesheet"}])
 ;;output of include-js ([:script
 {:type "text/javascript",   :src
  #<URI //code.jquery.com/jquery-1.10.1.min.js>}]
 [:script {:type "text/javascript", :src #<URI /js/uielements.js>}])

同样,在hiccup.element命名空间,Hiccup提供一个名为image的辅助函数去加载图片:

(image "/img/test.jpg")
[:img {:src #<URI /img/test.jpg>}]
(image "/img/test.jpg" "alt text")
[:img {:src #, :alt "alt text"}]

你已经见识了一些常用的函数,其实还有一些更有用的。大多数辅助函数可以在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路由得以完善。

(defroutes home-routes
 (GET "/" [name message error] (home name message error))
 (POST "/" [name message] (save-message name message)))

到目前为止,我们已完成创建路由并由此呈现页面,还能处理来自客户端的请求表单。正如我们先前提到的,除了由Ring和Compojure提供的,真实的应用还需要添加一些别的元素。接下来,让我们看看如何为我们的应用添加更多功能。

不少程序库能有效应对各种处理任务,比如会话管理、输入验证、身份认证。你依旧可以随意挑拣适合你的部件。

我们选择lib-noir[19]作为接下来的关注重点,因为应对Web程序的绝大多数任务,它都能胜任。我们之前通过介绍Hiccup的API,学习了它的一些特性及常见功能,同样,我们也来看看lib-noir是如何用的。

首先,为了能启用lib-noir,我们需将其添入项目描述文件project.clj。具体是在依赖项的vector里添加[lib-noir "0.7.6"]。

如果你的项目还正运行着,你务必先重启应用,让依赖项生效。接下来,我们再看看如何使用lib-noir为应用添加功能。

有些情况下,在执行某些操作之后,我们需要刻意将页面跳转到别的页面。比如,用户在注册页面完成账户注册之后,需要将用户重定向到主页。

既然要实现用户注册,我们就先添加一个注册页吧。第一步,新建一个命名空间,名为guestbook.routes.auth。与home命名空间的处理一样,需要引用其他的命名空间:

(ns guestbook.routes.auth
 (:require [compojure.core :refer [defroutes GET POST]]
      [guestbook.views.layout :as layout]
      [hiccup.form :refer
       [form-to label text-field password-field submit-button]]))

这个函数用于为我们呈现页面,并会为展示给用户一个表单,用于引导用户输入ID和密码。

(defn registration-page []
 (layout/common
  (form-to [:post "/register"]
       (label "id" "screen name")
       (text-field "id")
       [:br]
       (label "pass" "password")
       (password-field "pass")
       [:br]
       (label "pass1" "retype password")
       (password-field "pass1")
       [:br]
       (submit-button "create account"))))

看得出来,函数内部的表达方式有点累赘,每一个输入需要一个标签,然后还得添加一个换行。好在Hiccup使用标准Clojure数据结构表述,我们可以提取重复元素,抽象并构造一个辅助函数:

(defn control [field name text]
 (list (label name text)
    (field name)
    [:br]))

(defn registration-page []
 (layout/common
  (form-to [:post "/register"]
   (control text-field :id "screen name")
   (control password-field :pass "Password")
   (control password-field :pass1 "Retype Password")
   (submit-button "Create Account"))))

平时,我们会用一个vector来直接表述,但这次创建的函数使用list函数来包装。这是因为Hiccup使用vector来表达HTML标签,但是标签内容并不能用vector来表达。

既然已经创建了新页面,同时也要考虑为其增加一条对应的路由。这里,将路由处理封装到名为auth-routes的函数中:

(defroutes auth-routes
 (GET "/register" [_] (registration-page)))

上面的函数形参vector中使用了下划线(_),用在被执行的函数不使用此参数时,这种表达方式是Clojure约定俗成的用法。

由于我们已经创建了一条新路由,同样,我们也需要去更新我们的程序处理。我们需要在handler命名空间中引用这个新命名空间,同时为我们的程序添加路由,具体如下:

(ns guestbook.handler
 ...
 (:require ...
  [guestbook.routes.auth :refer [auth-routes]]))

...
(def app
 (handler/site
  (routes auth-routes home-routes app-routes)))

注意,因为路由中使用了(route/not-found "Not Found"),这条路由会覆盖所有定义在此之后的其他路由,新路由应该添加在app-routes前段。

如果你已经在REPL中运行着站点,那么你需要重启,让新的路由生效。

网站重启之后,则需要导航至http://localhost:3000/register确认页面能否正确加载。如果一切顺利,你现在就可以为注册页面添加处理了。

在成功注册之后,处理会将用户重定向到home页。重定向是个简单的map,包含状态、头、消息体:

{:status 302, :headers {"Location" "/"}, :body ""}

Ring在ring.util.response命名空间中提供了重定向功能。由于我们已经启用了lib-noir,使用noir.response/redirect取代之。lib-noir允许使用操作关键字表达重定向状态码。默认是:found,对应的重定向状态码是302。

我们需要引用这个命名空间才能访问它,将其添加到auth命名空间的:require表中。

(ns guestbook.routes.auth
 (:require ...
      [noir.response :refer [redirect]]))

现在我们可以在auth-routes定义中添加我们的handler。此刻,我们对输入密码做简单匹配检查判定,成功则重定向到home页,否则,我们刷新此页。

(defroutes auth-routes
 (GET "/register" [] (registration-page))
 (POST "/register" [id pass pass1]
    (if (= pass pass1)
     (redirect "/")
     (registration-page))))

在用户与程序交互过程中,我们需要以某种途径去记录用户会话状态。所幸lib-noir在noir.session命名空间已提供了一套管理会话的方法。将客户端会话表示为一个map用于记录,使用如下辅助函数来处理:

函数名后缀使用感叹号(!),说明此举会改变会话状态,这种通过在函数名上增加符号来表达操作的表示方式,是Clojure约定俗成的。让我们看个例子——实现login和logout页面,每个动作将对会话做对应更新。

使用lib-noir会话的同时,我们会封装app handler来访问会话中间件。由于标准处理并不关心会话,也并不在请求之间提供方法去持有状态,所以这种处理是有必要的。

中间件要求我们自己提供储存方式,这样会话状态将会得到持久化处理。可以使用Redis[20]存于内存或备份至外部存储。

在我们的应用中,我们简单使用ring.middleware.session.memory/memory-store来说明。首先在每个中间件和存储处理都要声明引用此命名空间。

(ns guestbook.handler
 ...
 (:require ...
  [noir.session :as session]
  [ring.middleware.session.memory
   :refer [memory-store]]))

下一步,我们将使用会话中间件封装我们的应用。wrap-noir-session中间件接受一个包含:store键的map参数。我们绑定此键到memory-store:

(def app  (->
  (handler/site
   (routes auth-routes
       home-routes
       app-routes))
  (session/wrap-noir-session
   {:store (memory-store)})))

现在我们看到的内容涉及创建登录页面并将用户添加到会话。我们打开auth命名空间,将如下函数添加入内:

(defn login-page []
 (layout/common
  (form-to [:post "/login"]
   (control text-field :id "screen name")
   (control password-field :pass "Password")
   (submit-button "login"))))

此函数创建一个包含用户ID和密码的登录表单,并使用通用布局封装。当用户点击提交按钮,表单会将一个HTTP发送给/login URI。

我们现在更新这个路由定义,为程序创建一个GET和POST的/login路由。为使其正常工作,我们同样需要在路由页面引用noir.session。

(ns guestbook.routes.auth
 (:require ...
      [noir.session :as session]))
...
(defroutes auth-routes  (GET "/register" [] (registration-page))  (POST "/register" [id pass pass1]     (if (= pass pass1)      (redirect "/")      (registration-page)))  (GET "/login" [] (login-page))  (POST "/login" [id pass]      (session/put! :user id)      (redirect "/")))

GET login路由简单调用login-page函数去显示页面。在重定向到home页面之前,POST login路由使用noir.session/put!函数和:user键将用户添加到会话。现在我们将浏览器定位到/login页面,试试新添加的功能。

对于会话中的那个用户,在我们的home函数构造页面的同时,可以调用(session/get :user)来查看,这样就能在更新home页面的同时显示用户ID。此举须先在home命名空间声明处放置noir.session的包含引用。

(ns guestbook.routes.home
 (:require ... [noir.session :as session])

guestbook-with-auth/src/guestbook/routes/home.clj
(defn home [& [name message error]]
 (layout/common
  [:h1 "Guestbook " (session/get :user)]
  [: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"))))

下一步,我们在创建注销页面时调用noir.session/clear!。当用户单击退出按钮,接下来将会清除此用户在会话中积累的一切信息。

(defroutes auth-routes
 (GET "/register" [] (registration-page))
 (POST "/register" [id pass pass1]
    (if (= pass pass1)
     (redirect "/")
     (registration-page)))

 (GET "/login" [] (login-page))
 (POST "/login" [id pass]
    (session/put! :user id)
    (redirect "/"))
 (GET "/logout" []
     (layout/common
      (form-to [:post "/logout"]
       (submit-button "logout"))))
 (POST "/logout" []
    (session/clear!)
    (redirect "/")))

切记,session命名空间必须在请求上下文时访问,这意味着不能在路由声明之外使用。

当创建表单时,我们需要某种途径去检查填写正确与否,并且还需要通知用户关于填写遗漏或项缺失。到目前为止,我们仅简单在参数中填充错误键并显示在页面上。

还是使用类似的办法,我们使用cond实现决策处理:显示有错误描述的登录页面,或者将用户添进会话并重定向页面:

(defn login-page [& [error]]
 (layout/common
  (if error [:div.error "Login error: " error])
  (form-to [:post "/login"]
   (control text-field :id "screen name")
   (control password-field :pass "Password")
   (submit-button "login"))))

(defn handle-login [id pass]
 (cond
  (empty? id)
  (login-page "screen name is required")
  (empty? pass)
  (login-page "password is required")
  (and (= "foo" id) (= "bar" pass))
  (do    (session/put! :user id)
   (redirect "/"))

  :else
  (login-page "authentication failed")))

下一步,我们更新POST /login路由,使用handle-login函数作为handler去处理。

(POST "/login" [id pass]
 (handle-login id pass))

尽管这种方式简单、可用,为了扩充更多规则,很快就会变得乏味。正好lib-noir提供了noir.validation命名空间,可以使用优雅的方式去处理输入验证。我们在auth命名空间引用它,见识一下它如何改善我们的验证处理。

(ns guestbook.routes.auth
 (:require ...
       [noir.validation
       :refer [rule errors? has-value? on-error]])

对于使用验证函数,我们一样需要将handler封装到wrap- noir-validation中间件。这里需要引用noir.validation:

(ns guestbook.handler
 ...
 (:require ...
      [noir.validation
       :refer [wrap-noir-validation]]))
guestbook-with-auth/src/guestbook/handler.clj (def app  (->   (handler/site    (routes auth-routes        home-routes        app-routes))    (wrap-base-url)    (session/wrap-noir-session     {:store (memory-store)})    (wrap-noir-validation)))

顺便说一声,如果你正运行着REPL,现在你需要通过重新加载程序来重编译路由。

这里有个noir.validation/rule辅助函数,可以取代cond来实现决策。每个规则都对内容判定,检查各自是否能通过。最后,函数会调用noir.validation/errors?去检查规则中是否产生错误。如果有,我们就显示登录页面;否则我们将用户记录到会话,并重定向到home页面。

(defn handle-login [id pass]
 (rule (has-value? id)
    [:id "screen name is required"])
 (rule (= id "foo")
    [:id "unknown user"])
 (rule (has-value? pass)
    [:pass "password is required"])
 (rule (= pass "bar")
    [:pass "invalid password"])

 (if (errors? :id :pass)
  (login-page)

  (do
   (session/put! :user id)
   (redirect "/"))))

我们按如下格式创建规则:

(rule validator [:field-name "error message"])

验证器可以表达为任何形式,只要最终返回布尔值即可。也可以为每个键设置多重错误,这些错误会被汇集到一个vector。当验证器返回false,将生成错误。

例如,我们写下(= id "foo"),id的值只要不是foo,就会生成错误。

我们这里为每一个项分别提供一个错误处理。其实可以创建一个辅助函数,用于将它们汇集起来,并统一为展示错误内容做进一步处理。

guestbook-with-auth/src/guestbook/routes/auth.clj
(defn format-error [[error]]
 [:p.error error])

我们现在更新control函数,在调用on-error时,传入控制名。这便实现了错误汇聚,对提供的键名使用format-error格式化。

guestbook-with-auth/src/guestbook/routes/auth.clj
(defn control [field name text]
 (list (on-error name format-error)
    (label name text)
    (field name)
    [:br]))

由于我们不再需要将错误定向到login-page,我们更新对应内容。

guestbook-with-auth/src/guestbook/routes/auth.clj
(defn login-page []
 (layout/common
  (form-to [:post "/login"]
   (control text-field :id "screen name")
   (control password-field :pass "Password")
   (submit-button "login"))))

总而言之,我们可以在需要验证的任何地方创建规则。每个规则会考察、判定此处是否合法。如果此处验证失败,就会生成错误内容并通过on-error辅助函数呈现给用户。

我们之所以可以这样做,是因为验证错误一定是当前的请求带来的。由于调用的这个函数为当前的请求负责处理和展现结果,所以它也应当处理对应的错误。

Lib-noir同样提供便捷途径去处理hash,并使用noir.util.crypt验证密码。这个命名空间提供两个名为encrypt 和compare的函数。前者用于密码加密、加盐(salts),后者用于对比明文密码和由前者生成的hash字符串。实际上,内部具体使用的是流行的jBCrypt库[21]处理的加密。

使用compare函数去验证看起来是这样:

(compare raw encrypted)

encrypt函数允许指定加盐,也生成并提供一个不加盐的版本。

(encrypt salt raw)
(encrypt raw)

我们之所以对密码加盐,是为了对抗彩虹表 (rainbow-table)[22]的攻击。彩虹表其实是预先将很多常见密码通过哈希计算生成的字典。此表是通过优化提高哈希查找效率,并且允许攻击者容易通过给定的哈希值来获取密码原文。而加盐操作是为密码追加随机内容再进行哈希,最终生成的哈希便不再容易被破解。

这里,我们同样需要在auth命名空间中添加引用:

(ns guestbook.routes.auth
 (:require ...
      [noir.util.crypt :as crypt])

至此,我们已经将用户状态保存在会话记录中。接下来,我们再看看当用户注册到站点时,如何固化用户详细信息。首先,我们在db命名空间下添加几个函数,用于访问数据库:实现一个写操作函数去添加用户,一个读操作函数检索用户。

guestbook-with-auth/src/guestbook/models/db.clj
(defn create-user-table []
 (sql/with-connection
  db
  (sql/create-table
   :users
   [:id "varchar(20) PRIMARY KEY"]
   [:pass "varchar(100)"])))

(defn add-user-record [user]
 (sql/with-connection db
  (sql/insert-record :users user)))

(defn get-user [id]
 (sql/with-connection db
  (sql/with-query-results
   res ["select * from users where id = ?" id] (first res))))

完成这些之后,我们需要重新加载db命名空间,使得新的函数生效,然后在REPL控制台运行(create-user-table)。

我们现在可以切换到auth命名空间,开始编写handle-registration函数。记住,我们一样也要在db命名空间声明引用。

(ns guestbook.routes.auth
 (:require ... [guestbook.models.db :as db]))

guestbook-with-auth/src/guestbook/routes/auth.clj
(defn handle-registration [id pass pass1]
 (rule (= pass pass1)
    [:pass "password was not retyped correctly"])
 (if (errors? :pass)
  (registration-page)
  (do
   (db/add-user-record {:id id :pass (crypt/encrypt pass)})
   (redirect "/login"))))

更新POST /register 路由,这些功能在被调用时将会生效。

(POST "/register" [id pass pass1]
   (handle-registration id pass pass1))

接下来,当一个用户试图登录时,我们会在登录处理函数中检查其授权。

guestbook-with-auth/src/guestbook/routes/auth.clj
(defn handle-login [id pass]
 (let [user (db/get-user id)]
  (rule (has-value? id)
     [:id "screen name is required"])
  (rule (has-value? pass)
     [:pass "password is required"])
  (rule (and user (crypt/compare pass (:pass user)))
     [:pass "invalid password"])
  (if (errors? :id :pass)
  (login-page)
  (do    (session/put! :user id)
   (redirect "/")))))

我们使用crypt/compare函数去比对此时提供的密码和其在注册中创建的哈希版本。

出于一些原因,我们可能会希望明确指定负载内容的类型,比如纯文本、JSON等。我们可以通过简单封装noir.response命名空间下的content-type函数实现。

(GET "/records" []
 (noir.response/content-type "text/plain" "some plain text"))

noir.response命名空间下有用于处理JSON和XML的辅助函数。比如JSON响应,就是将内建数据结构自动转换为JSON字符串。

(GET "/get-message" []
 (noir.response/json {:message "everything went better than expected!"})

这个回应辅助函数非常实用,用于应对客户端发起的Ajax请求。

我们已经说过了,Lib-noir提供非常多的实用特性。

cookies命名空间提供的函数用于读写cookie;io命名空间提供的函数可用于访问静态资源,并且也能处理文件上传;cache命名空间提供内容缓存的基础件;middleware命名空间提供数个辅助函数去创建通用类型的程序handler和封装;最后,route命名空间提供一个函数去创建受限路由。这有助于限制页面访问,我们放在“第5章 相册”来讨论这些内容。

在这一章,我们见识了如何通过Clojure搭建Web栈,以及一些常用程序库。我们谈及了如何与Ring、Compojure、lib-noir交互,通过完成比如输入验证和会话管理的任务来说明它们之间如何相互作用。

但愿你已能顺畅阅读,并理解在留言簿项目(我们在“第1章 起步”创建的那个项目)的代码。如果你还有疑惑,我强烈建议你去重新阅读“第1章”,并在REPL环境中尝试自己搭建这个例子。如果你还没来得及做,再提一点,借此机会把本章的例子带入留言簿程序做一遍。

在下一章,我们会使用Liberator建立REST服务。

[1]http://www.oracle.com/technetwork/java/index-jsp-135475.html

[2]http://www.eclipse.org/jetty/

[3]https://glassfish.java.net/

[4]http://tomcat.apache.org/

[5]http://aws.amazon.com/

[6]https://developers.google.com/appengine/

[7]https://www.heroku.com/

[8]http://jelastic.com/

[9]http://wsgi.readthedocs.org/en/latest/

[10]http://rack.github.io/

[11]http://www.w3.org/Protocols/rfc2616/rfc2616.html

[12]http://rubyonrails.org/

[13]http://www.sinatrarb.com/

[14]https://github.com/weavejester/hiccup

[15]https://github.com/cgrand/enlive

[16]https://github.com/yogthos/Selmer

[17]https://github.com/davidsantiago/stencil

[18]http://en.wikipedia.org/wiki/Single-page_application

[19]https://github.com/noir-clojure/lib-noir

[20]http://redis.io/

[21]http://www.mindrot.org/projects/jBCrypt/

[22] http://en.wikipedia.org/wiki/Rainbow_table


相关图书

深入理解Scala
深入理解Scala
Haskell并行与并发编程
Haskell并行与并发编程
Haskell函数式编程入门
Haskell函数式编程入门
Haskell趣学指南
Haskell趣学指南
Clojure编程乐趣
Clojure编程乐趣
Clojure程序设计
Clojure程序设计

相关文章

相关课程