Clojure驱动的Web开发

廖雪峰 / 文章 / ... / Reads: 32159 Edit

本文最早发布在IBM developerWorks

Clojure是运行在JVM之上的Lisp方言,提供了强大的函数式编程的支持。由于Java语言进化的缓慢,用Java编写大型应用程序时,代码往往十分臃肿,许多语言如Groovy、Scala等都把自身设计为一种可替代Java的,能直接编译为JVM字节码的语言。Clojure则提供了Lisp在JVM的实现。

Clojure经过几年的发展,其社区已经逐渐成熟,有许多活跃的开源项目,足以完成大型应用程序的开发。由Twitter开源的著名的分布式并行计算框架Storm就是用Clojure编写的。

Clojure提供了对Java的互操作调用,对于那些必须在JVM上继续开发的项目,Clojure可以利用Java遗留代码。对大多数基于SSH(Spring Struts Hibernate)的Java项目来说,是时候扔掉它们,用Clojure以一种全新的模式来进行开发了。

本文将简要介绍使用Clojure构建Web应用程序的开发环境和技术栈。相比SSH,相同的功能使用Clojure仅需极少的代码,并且无需在开发过程中不断重启服务器,可以极大地提升开发效率。

安装Clojure开发环境

由于Clojure运行在JVM上,我们只需要准备好JDK和Java标配的Eclipse开发环境,就可以开始Clojure开发了!

我们的开发环境是:

  • Java 8 SDK:可以从Oracle官方网站下载最新64位版本;

  • Eclipse Luna SR1:可以从Eclipse官方网站下载Eclipse IDE for Java Developers最新64位版本。

安装完JDK后,通过命令java -version确认JDK是否正确安装以及版本号:

$ java -version
java version "1.8.0_20"
Java(TM) SE Runtime Environment (build 1.8.0_20-b26)
Java HotSpot(TM) 64-Bit Server VM (build 25.20-b23, mixed mode)

Clojure开发环境可以通过Eclipse插件形式获得,Counterclockwise提供了非常完善的Clojure开发支持。

首先运行Eclipse,通过菜单“Help”-“Eclipse Marketplace...”打开Eclipse Marketplace,搜索关键字counterclockwise,点击Install安装:

find-counterclockwise

安装完Counterclockwise后需要重启Eclipse,然后,我们就可以新建一个Clojure Project了!

选择菜单“File”-“New”-“Project...”,选择“Clojure”-“Clojure Project”,填入名称“cljweb”,创建一个新的Clojure Project:

new-clj-proj

找到project.clj文件,把:dependencies中的Clojure版本由1.5.1改为最新版1.6.0

(defproject cljweb "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.6.0"]])

保存,然后你会注意到Leiningen会自动编译整个工程。

Leiningen是什么

Leiningen是Clojure的项目构建工具,类似于Maven。事实上,Leiningen底层完全使用Maven的包管理机制,只是Leiningen的构建脚本不是pom.xml,而是project.clj,它本身就是Clojure代码。

如果Leiningen没有自动运行,你可以点击菜单“Project”-“Build Automatically”,勾上后就会让Leiningen在源码改动后自动构建整个工程。

第一个Clojure版Hello World

src目录下找到自动生成的core.clj文件,注意到已经生成了如下代码:

(ns cljweb.core)

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

只需要添加一行代码,调用foo函数:

(println (foo "Clojure"))

然后,点击菜单“Run”-“Run”就可以直接运行了:

run-clj-code

Leiningen会启动一个REPL,并设置好classpath。第一次REPL启动会比较慢,原因是JVM的启动速度慢。在REPL中可以看到运行结果。REPL窗口本身还支持直接运行Clojure代码,这样你可以直接在REPL中测试代码,能极大地提高开发效率。

Clojure函数式编程

Clojure和Java最大的区别在于Clojure的函数是头等公民,并完全支持函数式编程。Clojure自身提供了一系列内置函数,使得编写的代码简洁而高效。

我们随便写几个函数来看看:

;; 定义自然数序列
(defn natuals []
  (iterate inc 1))

;; 定义奇数序列
(defn odds []
  (filter odd? (natuals)))

;; 定义偶数序列
(defn evens []
  (filter even? (natuals)))

;; 定义斐波那契数列
(defn fib []
  (defn fib-iter [a b]
    (lazy-seq (cons a (fib-iter b (+ a b)))))
  (fib-iter 0 1))

这些函数的特点是拥有Clojure的“惰性计算”特性,我们可以极其简洁地构造一个无限序列,然后通过高阶函数做任意操作:

;; 打印前10个数
(println (take 10 (natuals)))
(println (take 10 (odds)))
(println (take 10 (evens)))
(println (take 10 (fib)))

;; 打印1x2, 2x3, 3x4...
(println (take 10 (map * (natuals)
                         (drop 1 (natuals)))))

再识Clojure

Clojure自身到底是什么?Clojure自身只是一个clojure.jar文件,它负责把Clojure代码编译成JVM可以运行的.class文件。如果预先把Clojure代码编译为.class,那么运行时也不需要clojure.jar了。

Clojure自身也作为Maven的一个包,你应该可以在用户目录下找到Maven管理的clojure-1.6.0.jar以及源码:

.m2/repository/org/clojure/clojure/1.6.0/

如果要在命令行运行Clojure代码,需要自己把classpath设置好,入口函数是clojure.main,参数是要运行的.clj文件:

$ java -cp ~/.m2/repository/org/clojure/clojure/1.6.0/clojure-1.6.0.jar clojure.main cljweb/core.clj
Clojure: Hello, World!
nil
(1 2 3 4 5 6 7 8 9 10)
(1 3 5 7 9 11 13 15 17 19)
(2 4 6 8 10 12 14 16 18 20)
(0 1 1 2 3 5 8 13 21 34)
(2 6 12 20 30 42 56 72 90 110)

在Eclipse环境中,Leiningen已经帮你设置好了一切。

访问数据库

Java提供了标准的JDBC接口访问数据库,Clojure的数据库接口clojure.java.jdbc是对Java JDBC的封装。我们只需要引用clojure.java.jdbc以及对应的数据库驱动,就可以在Clojure代码中访问数据库。

clojure.java.jdbc是一个比较底层的接口。如果要使用DSL的模式来编写数据库代码,类似Java的Hibernate,则可以考虑几个DSL库。我们选择Korma来编写访问数据库的代码。

由于Clojure是Lisp方言,它继承了Lisp强大的“代码即数据”的功能,在Clojure代码中,编写SQL语句对应的DSL十分自然,完全无需Hibernate复杂的映射配置。

我们先配置好MySQL数据库,然后创建一个表来测试Clojure代码:

create table courses (
    id varchar(32) not null primary key,
    name varchar(50) not null,
    price real not null,
    online bool not null,
    days bigint not null
);

新建一个db.clj文件,选择菜单“File”-“New”-“Other...”,选择“Clojure”-“Clojure Namespace”,填入名称db,就可以创建一个db.clj文件。

在编写代码前,我们首先要在project.clj文件中添加依赖项:

[org.clojure/java.jdbc "0.3.6"]
[mysql/mysql-connector-java "5.1.25"]
[korma "0.3.0"]

使用Korma操作数据库十分简单,只需要先引用Korma:

(ns cljweb.db
  (:use korma.db
        korma.core))

定义数据库连接的配置信息:

(defdb korma-db (mysql {:db "test",
                        :host "localhost",
                        :port 3306,
                        :user "www",
                        :password "www"}))

然后定义一下要使用的entity,也就是表名:

(declare courses)
(defentity courses)

现在,就可以对数据库进行操作了。插入一条记录:

(insert courses
  (values { :id "s-201", :name "SQL", :price 99.9, :online false, :days 30 })))

使用Clojure内置的map类型,十分直观。

查询语句通过select宏实现了SQL DSL到Clojure代码的自然映射:

(select courses
        (where {:online false})
        (order :name :asc)))

这完全得益于Lisp的S表达式的威力,既不需要直接拼凑SQL,也不需要重新发明类似HQL的语法。

利用Korma的提供的sql-onlydry-run,可以打印出生成的SQL语句,但实际并不执行。

Web接口

传统的JavaEE使用Servlet接口来划分服务器和应用程序的界限,应用程序负责提供实现Servlet接口的类,服务器负责处理HTTP连接并转换为Servlet接口所需的HttpServletRequestHttpServletResponseServlet接口定义十分复杂,再加上Filter,所需的XML配置复杂度很高,而且测试困难。

Clojure的Web实现最常用的是Ring。Ring的设计来自Python的WSGI和Ruby的Rack,以WSGI为例,其接口设计十分简单,仅一个函数:

def application(env, start_response)

其中env是一个字典,start_response是响应函数。由于WSGI接口本身是纯函数,因此无需Filter接口就可以通过高阶函数对其包装,完成所有Filter的功能。

Ring在内部把Java标准的Servlet接口转换为简单的函数接口:

(defn handler [request]
  {:status 200
   :headers {"Content-Type" "text/html"}
   :body "Hello World"})

上述函数就完成了Servlet实现类的功能。其中request是一个map,返回值也是一个map,由:status:headers:body关键字指定HTTP的返回码、头和内容。

把一系列handler函数串起来就形成了一个处理链,每个链都可以对输入和输出进行处理,链的最后一个处理函数负责根据URL进行路由,这样,完整的Web处理栈就可以构造出来。

Ring把handler称为middleware,middleware基于Clojure的函数式编程模型,利用Clojure自带的->宏就可以直接串起来。

一个完整的Web程序只需要定义一个handler函数,并启动Ring内置的Jetty服务器即可:

;; hello.clj
(ns cljweb.hello
  (:require [ring.adapter.jetty :as jetty]))

(defn handler [request]
      {:status 200,
       :headers {"Content-Type" "text/html"}
       :body "<h1>Hello, world.</h1>"})

(defn start-server []
  (jetty/run-jetty handler {:host "localhost",
                            :port 3000}))

(start-server)

运行hello.clj,将启动内置的Jetty服务器,然后,打开浏览器,在地址栏输入http://localhost:3000/就可以看到响应:

7-hello

handler函数传入的request是一个map,如果你想查看request的内容,可以简单地返回:

(defn handler [request]
      {:status 200,
       :headers {"Content-Type" "text/html"}
       :body (str request)})

URL路由

要处理不同的URL请求,我们就需要在handler函数内根据URL进行路由。Ring本身只负责处理底层的handler函数,更高级的URL路由功能由上层框架完成。

Compojure就是轻量级的URL路由框架,我们要首先添加Compojure的依赖项:

[compojure "1.2.1"]

Compojure提供了defroutes宏来创建handler,它接收一系列URL映射,然后把它们组装到handler函数内部,并根据URL路由。一个简单的handler定义如下:

(ns cljweb.routes
  (:use [compojure.core]
        [compojure.route :only [not-found]]
        [ring.adapter.jetty :as jetty]))

(defroutes app-routes

  (GET "/" [] "<h1>Index page</h1>")

  (GET "/learn/:lang" [lang] (str "<h1>Learn " lang "</h1>"))

  (not-found "<h1>page not found!</h1>"))

;; start web server
(defn start-server []
  (jetty/run-jetty app-routes {:host "localhost",
                               :port 3000}))

(start-server)

defroutes创建了3个URL映射:

GET /处理首页的URL请求,它仅仅简单地返回一个字符串;

GET /learn/:lang处理符合/learn/:lang这种构造的URL,并且将URL中的参数自动作为参数传递进来,如果我们输入http://localhost:3000/learn/clojure,将得到如下响应:

cljweb-url-learn

not-found处理任何未匹配到的URL,例如:

cljweb-url-notfound

使用模板

复杂的HTML通常不可能在程序中拼接字符串完成,而是通过模板来渲染出HTML。模板的作用是创建一个使用变量占位符和简单的控制语句的HTML,在程序运行过程中,根据传入的model——通常是一个map,替换掉变量,执行一些控制语句,最终得到HTML。

已经有好几种基于Clojure创建的模板引擎,但是基于Django模板设计思想的Selmer最适合HTML开发。

Selmer的使用十分简单。首先添加依赖:

[selmer "0.7.2"]

然后创建一个cljweb.templ的namespace来测试Selmer:

(ns cljweb.templ)

(use 'selmer.parser)

(selmer.parser/cache-off!)

(selmer.parser/set-resource-path! (clojure.java.io/resource "templates"))

(render-file "test.html" {:title "Selmer Template",
                          :name "Michael",
                          :now (new java.util.Date)})

在开发阶段,用cache-off!关掉缓存,以便使得模板的改动可以立刻更新。

使用set-resource-path!设定模板的查找路径。我们把模板的根目录设置为(clojure.java.io/resource "templates"),因此,模板文件的存放位置必须在目录resources/templates下:

selmer-template

创建一个test.html模板:

<html>
<head>
    <title>{{ title }}</title>
</head>
<body>
    <h1>Welcome, {{ name }}</h1>
    <p>Time: {{ now|date:"yyyy-MM-dd HH:mm" }}</p>
</body>
</html>

运行代码,可以看到REPL打印出了render-file函数返回的结果:

clj-repl-test-selmer

配置middleware

Compojure可以方便地定义URL路由,但是,完整的Web应用程序还需要能解析URL参数、处理Cookie、返回JSON类型等,这些任务都可以通过Ring自带的middleware完成。

我们创建一个cljweb.web的namespace作为入口,Ring自带的middleware都提供wrap函数,可以用Clojure的->宏把它们串联起来:

(ns cljweb.web
  (:require
    [ring.adapter.jetty :as jetty]
    [ring.middleware.cookies :as cookies]
    [ring.middleware.params :as params]
    [ring.middleware.keyword-params :as keyword-params]
    [ring.middleware.json :as json]
    [ring.middleware.resource :as resource]
    [ring.middleware.stacktrace :as stacktrace]
    [cljweb.templating :as templating]
    [cljweb.urlhandlers :as urlhandlers]))

(def app
  (-> urlhandlers/app-routes
      (resource/wrap-resource (clojure.java.io/resource "resources")) ;; static resource
      templating/wrap-template-response  ;; render template
      json/wrap-json-response            ;; render json
      json/wrap-json-body                ;; request json
      stacktrace/wrap-stacktrace-web     ;; wrap-stacktrace-log
      keyword-params/wrap-keyword-params ;; convert parameter name to keyword
      cookies/wrap-cookies ;; get / set cookies
      params/wrap-params   ;; query string and url-encoded form
  ))

每个middleware只负责一个任务,每个middleware接受request,返回response,它们都有机会修改requestresponse,因此顺序很重要:

middlewares-chain

例如,cookies负责把request的Cookie字符串解析为map并以关键字:cookies存储到request中,后续的处理程序可以直接从request拿到:cookies

cookie-request

同时,如果在response中找到了:cookies,就把它转换为Cookie字符串并放入response:headers中,服务器就会在HTTP响应中加上Set-Cookie的头:

cookie-response

Ring没有内置能渲染Selmer模板的middleware,但是middleware不过是一个简单的函数,我们可以自己编写一个wrap-template-response,它在response中查找:body以及:body所包含的:model:template,如果找到了,就通过Selmer渲染模板,并将渲染结果作为string放到response:body中,服务器就可以读取response:body并输出HTML:

(ns cljweb.templating
  (:use ring.util.response
        [selmer.parser :as parser]))

(parser/cache-off!)

(parser/set-resource-path! (clojure.java.io/resource "templates"))

(defn- try-render [response]
  (let [body (:body response)]
    (if (map? body)
      (let [[model template] [(:model body) (:template body)]]
        (if (and (map? model) (string? template))
          (parser/render-file template model))))))

(defn wrap-template-response
  [handler]
  (fn [request]
    (let [response (handler request)]
      (let [render-result (try-render response)]
        (if (nil? render-result)
          response
          (let [templ-response (assoc response :body render-result)]
            (if (contains? (:headers response) "Content-Type")
              templ-response
              (content-type templ-response "text/html;charset=utf-8"))))))))

处理REST API

绝大多数Web应用程序都会选择REST风格的API,使用JSON作为输入和输出。在Clojure中,JSON可以直接映射到Clojure的数据类型map,因此,只需添加处理JSON的相关middleware就能处理REST。首先添加依赖:

[ring/ring-json "0.3.1"]

在middleware中,添加wrap-json-responsewrap-json-body

(def app
  (-> urlhandlers/app-routes
      (resource/wrap-resource (clojure.java.io/resource "resources")) ;; static resource
      templating/wrap-template-response  ;; render template
      json/wrap-json-response            ;; render json
      json/wrap-json-body                ;; request json
      stacktrace/wrap-stacktrace-web     ;; wrap-stacktrace-log
      keyword-params/wrap-keyword-params ;; convert parameter name to keyword
      cookies/wrap-cookies ;; get / set cookies
      params/wrap-params   ;; query string and url-encoded form
  ))

wrap-json-body如果读到Content-Type是application/json,就会把:body从字符串变为解析后的数据格式。wrap-json-response如果读到:body是一个map或者vector,就会把:body序列化为JSON字符串,并重置:body为字符串,同时添加Content-Type为application/json

因此,我们在URL处理函数中,如果要返回JSON,只需要返回map,如果要读取JSON,只需要读取:body

(defroutes app-routes

  (GET "/rest/courses" [] (response { :courses (get-courses) }))

  (POST "/rest/courses" [] (fn [request]
                             (let [c (:body request)
                                   id (str "c-" (System/currentTimeMillis))]
                               (create-course! (assoc c :id id, :online true,))
                               (response (get-course id)))))
  (not-found "<h1>page not found!</h1>"))

把数据库操作、模板以及其他的URL处理函数都包含进来,我们就创建好了一个完整的基于Clojure的Web应用程序。

右键点击项目,在弹出菜单选择“Leiningen”,“Generate Leiningen Command Line”,在弹出的输入框里:

run-lein-cmd

输入命令:

lein ring server

将启动Ring内置的Jetty服务器,并自动打开浏览器,定位到http://localhost:3000/

cljweb-index

以这种方式启动服务器的好处是对代码做任何修改,无需重启服务器就可以直接生效,只要在project.clj中加上:

:ring {:handler cljweb.web/app
       :auto-reload? true
       :auto-refresh? true}

部署

要在服务器部署Clojure编写的Web应用程序,有好几种方法,一种是用Leiningen命令:

$ lein uberjar

把所有源码和依赖项编译并打包成一个独立的jar包(可能会很大),打包前需要先编写一个main函数并在project.clj中指定:

:main cljweb.web

把这个jar包上传到服务器上就可以直接通过Java命令运行:

$ java -jar cljweb-0.1.0-SNAPSHOT-standalone.jar start

需要加上参数start是因为我们在main函数中通过start参数来判断是否启动Jetty服务器:

(defn -main [& args]
  (if (= "start" (first args))
    (start-server)))

要以传统的war包形式部署,可以使用命令:

$ lein ring war

这将创建一个.war文件,部署到标准的JavaEE服务器上即可。

小结

Clojure作为一种运行在JVM平台上的Lisp方言,它既拥有Lisp强大的S表达式、宏、函数式编程等特性,又充分利用了JVM这种高度优化的虚拟机平台,和传统的JavaEE系统相比,Clojure不仅代码简洁,能极大地提升开发效率,还拥有一种与JavaEE所不同的开发模型。传统的Java开发人员需要转变固有思维,利用Clojure替代Java,完全可以编写出更简单,更易维护的代码。

参考资料

源码下载:https://github.com/michaelliao/cljweb/

Clojure官方网站:了解并下载Clojure的最新版本;

Leiningen官方网站:了解并下载Leiningen的最新版本;

Korma官方网站:获取Korma源码并阅读在线文档;

Ring官方网站:获取Ring源码并阅读在线文档;

Compojure官方网站:获取Compojure源码并阅读在线文档。

关于作者

廖雪峰,精通Java/Objective-C/Python/C#/Ruby/Lisp,独立iOS开发者,对开源框架有深入研究,著有《Spring 2.0核心技术与最佳实践》一书,其官方博客是http://www.liaoxuefeng.com/,官方微博是@廖雪峰

Comments

Make a comment

Author: 廖雪峰

Publish at: ...

关注公众号不定期领红包:

加入知识星球社群:

关注微博获取实时动态: