Menulis Clojure Webapps dengan Ring

1. Perkenalan

Ring adalah perpustakaan untuk menulis aplikasi web di Clojure . Ini mendukung semua yang diperlukan untuk menulis aplikasi web berfitur lengkap dan memiliki ekosistem yang berkembang untuk membuatnya lebih kuat.

Dalam tutorial ini, kita akan memberikan pengantar tentang Ring, dan menunjukkan beberapa hal yang dapat kita capai dengannya.

Ring bukanlah kerangka kerja yang dirancang untuk membuat REST API, seperti banyak toolkit modern lainnya. Ini adalah kerangka kerja tingkat rendah untuk menangani permintaan HTTP secara umum , dengan fokus pada pengembangan web tradisional. Namun, beberapa pustaka membangun di atasnya untuk mendukung banyak struktur aplikasi lain yang diinginkan.

2. Dependensi

Sebelum kita dapat mulai bekerja dengan Ring, kita perlu menambahkannya ke proyek kita. Dependensi minimum yang kita butuhkan adalah :

  • ring / ring-core
  • ring / ring-jetty-adapter

Kami dapat menambahkan ini ke proyek Leiningen kami:

 :dependencies [[org.clojure/clojure "1.10.0"] [ring/ring-core "1.7.1"] [ring/ring-jetty-adapter "1.7.1"]]

Kami kemudian dapat menambahkan ini ke proyek minimal:

(ns ring.core (:use ring.adapter.jetty)) (defn handler [request] {:status 200 :headers {"Content-Type" "text/plain"} :body "Hello World"}) (defn -main [& args] (run-jetty handler {:port 3000}))

Di sini, kita telah mendefinisikan fungsi penangan - yang akan segera kita bahas - yang selalu mengembalikan string "Hello World". Juga, kita telah menambahkan fungsi utama kita untuk menggunakan penangan ini - itu akan mendengarkan permintaan pada port 3000.

3. Konsep Inti

Leiningen memiliki beberapa konsep inti di mana semuanya dibangun: Permintaan, Respons, Penangan, dan Middleware.

3.1. Permintaan

Permintaan adalah representasi dari permintaan HTTP yang masuk. Cincin mewakili permintaan sebagai peta, memungkinkan aplikasi Clojure kita berinteraksi dengan bidang individu dengan mudah . Ada seperangkat kunci standar di peta ini, termasuk namun tidak terbatas pada:

  • : uri - Jalur URI lengkap.
  • : query-string - String kueri lengkap.
  • : request-method - Metode permintaan, salah satu dari : get,: head,: post,: put,: delete atau : options.
  • : headers - Peta dari semua header HTTP yang diberikan untuk permintaan.
  • : body - InputStream yang mewakili isi permintaan, jika ada.

Middleware dapat menambahkan lebih banyak kunci ke peta ini sesuai kebutuhan.

3.2. Tanggapan

Demikian pula, tanggapan adalah representasi dari tanggapan HTTP keluar. Cincin juga mewakili ini sebagai peta dengan tiga tombol standar :

  • : status - Kode status yang akan dikirim kembali
  • : headers - Peta dari semua header HTTP yang akan dikirim kembali
  • : body - Badan opsional untuk dikirim kembali

Seperti sebelumnya, Middleware dapat mengubah ini antara penangan kami yang membuatnya dan hasil akhir yang dikirimkan ke klien .

Ring juga menyediakan beberapa bantuan untuk membuat tanggapan lebih mudah .

Yang paling dasar adalah fungsi ring.util.response / response , yang membuat respons sederhana dengan kode status 200 OK :

ring.core=> (ring.util.response/response "Hello") {:status 200, :headers {}, :body "Hello"}

Ada beberapa metode lain yang menyertai ini untuk kode status umum - misalnya, permintaan buruk , tidak ditemukan, dan pengalihan :

ring.core=> (ring.util.response/bad-request "Hello") {:status 400, :headers {}, :body "Hello"} ring.core=> (ring.util.response/created "/post/123") {:status 201, :headers {"Location" "/post/123"}, :body nil} ring.core=> (ring.util.response/redirect "//ring-clojure.github.io/ring/") {:status 302, :headers {"Location" "//ring-clojure.github.io/ring/"}, :body ""}

Kami juga memiliki metode status yang akan mengubah respons yang ada menjadi kode status arbitrer:

ring.core=> (ring.util.response/status (ring.util.response/response "Hello") 409) {:status 409, :headers {}, :body "Hello"}

Kami kemudian memiliki beberapa metode untuk menyesuaikan fitur lain dari respons dengan cara yang sama - misalnya, jenis konten, header, atau set-cookie :

ring.core=> (ring.util.response/content-type (ring.util.response/response "Hello") "text/plain") {:status 200, :headers {"Content-Type" "text/plain"}, :body "Hello"} ring.core=> (ring.util.response/header (ring.util.response/response "Hello") "X-Tutorial-For" "Baeldung") {:status 200, :headers {"X-Tutorial-For" "Baeldung"}, :body "Hello"} ring.core=> (ring.util.response/set-cookie (ring.util.response/response "Hello") "User" "123") {:status 200, :headers {}, :body "Hello", :cookies {"User" {:value "123"}}}

Perhatikan bahwa metode set-cookie menambahkan entri baru ke peta respons . Ini membutuhkan middleware wrap-cookies untuk memprosesnya dengan benar agar bisa bekerja.

3.3. Penangan

Sekarang setelah kita memahami permintaan dan tanggapan, kita bisa mulai menulis fungsi penangan untuk mengikatnya.

Handler adalah fungsi sederhana yang mengambil permintaan masuk sebagai parameter dan mengembalikan respons keluar . Apa yang kami lakukan dalam fungsi ini sepenuhnya tergantung pada aplikasi kami, selama sesuai dengan kontrak ini.

Paling sederhana, kita bisa menulis fungsi yang selalu mengembalikan respons yang sama:

(defn handler [request] (ring.util.response/response "Hello"))

Kami juga dapat berinteraksi dengan permintaan tersebut sesuai kebutuhan.

Misalnya, kita bisa menulis penangan untuk mengembalikan Alamat IP masuk:

(defn check-ip-handler [request] (ring.util.response/content-type (ring.util.response/response (:remote-addr request)) "text/plain"))

3.4. Middleware

Middleware adalah nama yang umum di beberapa bahasa tetapi tidak begitu umum di dunia Java . Secara konseptual mereka mirip dengan Servlet Filters dan Spring Interceptors.

In Ring, middleware refers to simple functions that wrap the main handler and adjusts some aspects of it in some way. This could mean mutating the incoming request before it's processed, mutating the outgoing response after it's generated or potentially doing nothing more than logging how long it took to process.

In general, middleware functions take a first parameter of the handler to wrap and returns a new handler function with the new functionality.

The middleware can use as many other parameters as needed. For example, we could use the following to set the Content-Type header on every response from the wrapped handler:

(defn wrap-content-type [handler content-type] (fn [request] (let [response (handler request)] (assoc-in response [:headers "Content-Type"] content-type))))

Reading through it we can see that we return a function that takes a request – this's the new handler. This will then call the provided handler and then return a mutated version of the response.

We can use this to produce a new handler by simply chaining them together:

(def app-handler (wrap-content-type handler "text/html"))

Clojure also offers a way to chain many together in a more natural way – by the use of Threading Macros. These are a way to provide a list of functions to call, each with the output of the previous one.

In particular, we want the Thread First macro, ->. This will allow us to call each middleware with the provided value as the first parameter:

(def app-handler (-> handler (wrap-content-type "text/html") wrap-keyword-params wrap-params))

This has then produced a handler that's the original handler wrapped in three different middleware functions.

4. Writing Handlers

Now that we understand the components that make up a Ring application, we need to know what we can do with the actual handlers. These are the heart of the entire application and is where the majority of the business logic will go.

We can put whatever code we wish into these handlers, including database access or calling other services. Ring gives us some additional abilities for working directly with the incoming requests or outgoing responses that are very useful as well.

4.1. Serving Static Resources

One of the simplest functions that any web application can perform is to serve up static resources. Ring provides two middleware functions to make this easy – wrap-file and wrap-resource.

The wrap-file middleware takes a directory on the filesystem. If the incoming request matches a file in this directory then that file gets returned instead of calling the handler function:

(use 'ring.middleware.file) 
(def app-handler (wrap-file your-handler "/var/www/public"))

In a very similar manner, the wrap-resource middleware takes a classpath prefix in which it looks for the files:

(use 'ring.middleware.resource) 
(def app-handler (wrap-resource your-handler "public"))

In both cases, the wrapped handler function is only ever called if a file isn't found to return to the client.

Ring also provides additional middleware to make these cleaner to use over the HTTP API:

(use 'ring.middleware.resource 'ring.middleware.content-type 'ring.middleware.not-modified) (def app-handler (-> your-handler (wrap-resource "public") wrap-content-type wrap-not-modified)

The wrap-content-type middleware will automatically determine the Content-Type header to set based on the filename extension requested. The wrap-not-modified middleware compares the If-Not-Modified header to the Last-Modified value to support HTTP caching, only returning the file if it's needed.

4.2. Accessing Request Parameters

When processing a request, there are some important ways that the client can provide information to the server. These include query string parameters – included in the URL and form parameters – submitted as the request payload for POST and PUT requests.

Before we can use parameters, we must use the wrap-params middleware to wrap the handler. This correctly parses the parameters, supporting URL encoding, and makes them available to the request. This can optionally specify the character encoding to use, defaulting to UTF-8 if not specified:

(def app-handler (-> your-handler (wrap-params {:encoding "UTF-8"}) ))

Once done, the request will get updated to make the parameters available. These go into appropriate keys in the incoming request:

  • :query-params – The parameters parsed out of the query string
  • :form-params – The parameters parsed out of the form body
  • :params – The combination of both :query-params and :form-params

We can make use of this in our request handler exactly as expected.

(defn echo-handler [{params :params}] (ring.util.response/content-type (ring.util.response/response (get params "input")) "text/plain"))

This handler will return a response containing the value from the parameter input.

Parameters map to a single string if only one value is present, or to a list if multiple values are present.

For example, we get the following parameter maps:

// /echo?input=hello {"input "hello"} // /echo?input=hello&name=Fred {"input "hello" "name" "Fred"} // /echo?input=hello&input=world {"input ["hello" "world"]}

4.3. Receiving File Uploads

Often we want to be able to write web applications that users can upload files to. In the HTTP protocol, this is typically handled using Multipart requests. These allow for a single request to contain both form parameters and a set of files.

Ring comes with a middleware called wrap-multipart-params to handle this kind of request. This is similar to the way that wrap-params parses simple requests.

wrap-multipart-params automatically decodes and stores any uploaded files onto the file system and tells the handler where they are for it to work with them:

(def app-handler (-> your-handler wrap-params wrap-multipart-params ))

By default, the uploaded files get stored in the temporary system directory and automatically deleted after an hour. Note that this does require that the JVM is still running for the next hour to perform the cleanup.

If preferred, there's also an in-memory store, though obviously, this risks running out of memory if large files get uploaded.

We can also write our storage engines if needed, as long as it fulfills the API requirements.

(def app-handler (-> your-handler wrap-params (wrap-multipart-params {:store ring.middleware.multipart-params.byte-array/byte-array-store}) ))

Once this middleware is set up, the uploaded files are available on the incoming request object under the params key. This is the same as using the wrap-params middleware. This entry is a map containing the details needed to work with the file, depending on the store used.

For example, the default temporary file store returns values:

 {"file" {:filename "words.txt" :content-type "text/plain" :tempfile #object[java.io.File ...] :size 51}}

Where the :tempfile entry is a java.io.File object that directly represents the file on the file system.

4.4. Working With Cookies

Cookies are a mechanism where the server can provide a small amount of data that the client will continue to send back on subsequent requests. This is typically used for session IDs, access tokens, or persistent user data such as the configured localization settings.

Ring has middleware that will allow us to work with cookies easily. This will automatically parse cookies on incoming requests, and will also allow us to create new cookies on outgoing responses .

Configuring this middleware follows the same patterns as before:

(def app-handler (-> your-handler wrap-cookies ))

At this point, all incoming requests will have their cookies parsed and put into the :cookies key in the request. This will contain a map of the cookie name and value:

{"session_id" {:value "session-id-hash"}}

We can then add cookies to outgoing responses by adding the :cookies key to the outgoing response. We can do this by creating the response directly:

{:status 200 :headers {} :cookies {"session_id" {:value "session-id-hash"}} :body "Setting a cookie."}

There's also a helper function that we can use to add cookies to responses, in a similar way to how earlier we could set status codes or headers:

(ring.util.response/set-cookie (ring.util.response/response "Setting a cookie.") "session_id" "session-id-hash")

Cookies can also have additional options set on them, as needed for the HTTP specification. If we're using set-cookie then we provide these as a map parameter after the key and value. The keys to this map are:

  • :domain – The domain to restrict the cookie to
  • :path – The path to restrict the cookie to
  • :securetrue to only send the cookie on HTTPS connections
  • :http-onlytrue to make the cookie inaccessible to JavaScript
  • :max-age – The number of seconds after which the browser deletes the cookie
  • :expires – A specific timestamp after which the browser deletes the cookie
  • :same-site – If set to :strict, then the browser won't send this cookie back with cross-site requests.
(ring.util.response/set-cookie (ring.util.response/response "Setting a cookie.") "session_id" "session-id-hash" {:secure true :http-only true :max-age 3600})

4.5. Sessions

Cookies give us the ability to store bits of information that the client sends back to the server on every request. A more powerful way of achieving this is to use sessions. These get stored entirely on the server, but the client maintains the identifier that determines which session to use.

As with everything else here, sessions are implemented using a middleware function:

(def app-handler (-> your-handler wrap-session ))

By default, this stores session data in memory. We can change this if needed, and Ring comes with an alternative store that uses cookies to store all of the session data.

As with uploading files, we can provide our storage function if needed.

(def app-handler (-> your-handler wrap-cookies (wrap-session {:store (cookie-store {:key "a 16-byte secret"})}) ))

We can also adjust the details of the cookie used to store the session key.

For example, to make it so that the session cookie persists for one hour we could do:

(def app-handler (-> your-handler wrap-cookies (wrap-session {:cookie-attrs {:max-age 3600}}) ))

The cookie attributes here are the same as supported by the wrap-cookies middleware.

Sessions can often act as data stores to work with. This doesn't always work as well in a functional programming model, so Ring implements them slightly differently.

Instead, we access the session data from the request, and we return a map of data to store into it as part of the response. This is the entire session state to store, not only the changed values.

For example, the following keeps a running count of how many times the handler has been requested:

(defn handler [{session :session}] (let [count (:count session 0) session (assoc session :count (inc count))] (-> (response (str "You accessed this page " count " times.")) (assoc :session session))))

Working this way, we can remove data from the session simply by not including the key. We can also delete the entire session by returning nil for the new map.

(defn handler [request] (-> (response "Session deleted.") (assoc :session nil)))

5. Leiningen Plugin

Ring provides a plugin for the Leiningen build tool to aid both development and production.

We set up the plugin by adding the correct plugin details to the project.clj file:

 :plugins [[lein-ring "0.12.5"]] :ring {:handler ring.core/handler}

It's important that the version of lein-ring is correct for the version of Ring. Here we've been using Ring 1.7.1, which means we need lein-ring 0.12.5. In general, it's safest to just use the latest version of both, as seen on Maven central or with the lein search command:

$ lein search ring-core Searching clojars ... [ring/ring-core "1.7.1"] Ring core libraries. $ lein search lein-ring Searching clojars ... [lein-ring "0.12.5"] Leiningen Ring plugin

The :handler parameter to the :ring call is the fully-qualified name of the handler that we want to use. This can include any middleware that we've defined.

Using this plugin means that we no longer need a main function. We can use Leiningen to run in development mode, or else we can build a production artifact for deployment purposes. Our code now comes down exactly to our logic and nothing more.

5.1. Building a Production Artifact

Once this is set up, we can now build a WAR file that we can deploy to any standard servlet container:

$ lein ring uberwar 2019-04-12 07:10:08.033:INFO::main: Logging initialized @1054ms to org.eclipse.jetty.util.log.StdErrLog Created ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT-standalone.war

We can also build a standalone JAR file that will run our handler exactly as expected:

$ lein ring uberjar Compiling ring.core 2019-04-12 07:11:27.669:INFO::main: Logging initialized @3016ms to org.eclipse.jetty.util.log.StdErrLog Created ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT.jar Created ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT-standalone.jar

This JAR file will include a main class that will start the handler in the embedded container that we included. This will also honor an environment variable of PORT allowing us to easily run it in a production environment:

PORT=2000 java -jar ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT-standalone.jar 2019-04-12 07:14:08.954:INFO::main: Logging initialized @1009ms to org.eclipse.jetty.util.log.StdErrLog WARNING: seqable? already refers to: #'clojure.core/seqable? in namespace: clojure.core.incubator, being replaced by: #'clojure.core.incubator/seqable? 2019-04-12 07:14:10.795:INFO:oejs.Server:main: jetty-9.4.z-SNAPSHOT; built: 2018-08-30T13:59:14.071Z; git: 27208684755d94a92186989f695db2d7b21ebc51; jvm 1.8.0_77-b03 2019-04-12 07:14:10.863:INFO:oejs.AbstractConnector:main: Started [email protected]{HTTP/1.1,[http/1.1]}{0.0.0.0:2000} 2019-04-12 07:14:10.863:INFO:oejs.Server:main: Started @2918ms Started server on port 2000

5.2. Running in Development Mode

For development purposes, we can run the handler directly from Leiningen without needing to build and run it manually. This makes things easier for testing our application in a real browser:

$ lein ring server 2019-04-12 07:16:28.908:INFO::main: Logging initialized @1403ms to org.eclipse.jetty.util.log.StdErrLog 2019-04-12 07:16:29.026:INFO:oejs.Server:main: jetty-9.4.12.v20180830; built: 2018-08-30T13:59:14.071Z; git: 27208684755d94a92186989f695db2d7b21ebc51; jvm 1.8.0_77-b03 2019-04-12 07:16:29.092:INFO:oejs.AbstractConnector:main: Started [email protected]{HTTP/1.1,[http/1.1]}{0.0.0.0:3000} 2019-04-12 07:16:29.092:INFO:oejs.Server:main: Started @1587ms

This also honors the PORT environment variable if we've set that.

Additionally, there's a Ring Development library that we can add to our project. If this is available, then the development server will attempt to automatically reload any detected source changes. This can give us an efficient workflow of changing the code and seeing it live in our browser. This requires the ring-devel dependency adding:

[ring/ring-devel "1.7.1"]

6. Conclusion

Pada artikel ini, kami memberikan pengantar singkat tentang perpustakaan Ring sebagai sarana untuk menulis aplikasi web di Clojure. Mengapa tidak mencobanya di proyek berikutnya?

Contoh dari beberapa konsep yang telah kami bahas di sini dapat dilihat di GitHub.