1.2 你的第一个工程
你的留言簿应该已经在控制台运行了,可以通过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)吧。即使你阅读这些代码会感觉有点吃力,也不必担心,在后面的章节中你还有机会弄明白。相比纠缠于每个函数的细枝末节,目前应把注意力放在如何组织我们的应用,以及如何拆分应用逻辑更为重要。
在前面,我们曾经用纯手工的方式创建了一个录入表单。现在,我们打算用一个更好的实现来替代它,这会用到Hiccup10库提供的辅助函数。
为了使用这些函数,需要把库导入我们的命名空间,像下面这样修改命名空间的声明:(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.clj11文件中加入对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)提供的有用特性。这些特性包括代码完成、结构化的代码编辑,以及集成的依赖管理。
所以,我建议你花些时间去尝试一下那些更为成熟的开发环境,例如Eclipse12或者Emacs13。本书的剩余部分假定以Eclipse作为我们的开发环境,不过,无论你选用的是哪种编辑器,都没有任何问题。如需了解其他可选的IDE,不妨参考“附录1 选择IDE”。
你会发现,在开发应用的过程中,我们大量使用了REPL。因此,对于Clojure开发环境而言,是否集成了REPL可谓有着天壤之别。能在REPL中执行代码,就意味着你能获得更快的反馈周期,从而显著地提升生产力。
在本章中,我们演示了如何设置开发环境,以及如何搭建一个典型的Clojure Web应用。下一章,我们将关注那些构成Clojure Web栈的核心库。你将会了解到以下内容:请求响应的生命周期、定义路由、会话管理,以及利用中间件强化核心处理请求功能。