Перейти к основному содержимому

Написание веб-приложений Clojure с помощью Ring

· 15 мин. чтения

1. Введение

Ring — это библиотека для написания веб-приложений на языке Clojure . Он поддерживает все необходимое для написания полнофункциональных веб-приложений и имеет процветающую экосистему, которая делает его еще более мощным.

В этом уроке мы познакомимся с Ring и покажем некоторые вещи, которых мы можем достичь с его помощью.

Ring — это не фреймворк, предназначенный для создания REST API, как многие современные наборы инструментов. Это низкоуровневая структура для обработки HTTP-запросов в целом с упором на традиционную веб-разработку. Однако некоторые библиотеки строятся на его основе для поддержки многих других желаемых структур приложений.

2. Зависимости

Прежде чем мы сможем начать работать с Ring, нам нужно добавить его в наш проект. Минимальные зависимости, которые нам нужны :

Мы можем добавить их в наш проект Leiningen:

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

Затем мы можем добавить это к минимальному проекту:

(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}))

Здесь мы определили функцию-обработчик, которую мы вскоре рассмотрим, которая всегда возвращает строку «Hello World». Кроме того, мы добавили нашу основную функцию для использования этого обработчика — он будет прослушивать запросы на порту 3000.

3. Основные концепции

В Leiningen есть несколько основных концепций, вокруг которых все строится: запросы, ответы, обработчики и промежуточное ПО.

3.1. Запросы

Запросы — это представление входящих HTTP-запросов. Ring представляет запрос в виде карты, что позволяет нашему приложению Clojure легко взаимодействовать с отдельными полями . На этой карте есть стандартный набор ключей, включая, помимо прочего:

  • :uri — полный путь URI.
  • :query-string — Полная строка запроса.
  • :request-method — метод запроса, один из :get, :head, :post, :put, :delete или :options.
  • :headers — карта всех заголовков HTTP, предоставленных запросу.
  • :bodyInputStream , представляющий тело запроса, если он присутствует.

ПО промежуточного слоя может добавлять дополнительные ключи к этой карте по мере необходимости.

3.2. Ответы

Точно так же ответы представляют собой исходящие ответы HTTP. Кольцо также представляет их как карты с тремя стандартными ключами :

  • :status – код состояния для отправки
  • : headers — карта всех заголовков HTTP для отправки
  • : body — необязательное тело для отправки обратно

Как и прежде, ПО промежуточного слоя может изменить это между нашим обработчиком, производящим его, и конечным результатом, отправляемым клиенту .

Ring также предоставляет несколько помощников, облегчающих построение ответов .

Самой простой из них является функция ring.util.response/response , которая создает простой ответ с кодом состояния 200 OK :

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

Есть несколько других методов, которые используются вместе с этим для общих кодов состояния, например, bad-request , not-found и redirect :

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 "https://ring-clojure.github.io/ring/")
{:status 302, :headers {"Location" "https://ring-clojure.github.io/ring/"}, :body ""}

У нас также есть метод состояния , который преобразует существующий ответ в любой произвольный код состояния:

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

Затем у нас есть несколько методов для аналогичной настройки других функций ответа — например, content-type, header или 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" "ForEach")
{:status 200, :headers {"X-Tutorial-For" "ForEach"}, :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"}}}

Обратите внимание, что метод set-cookie добавляет совершенно новую запись в карту ответов . Это требует, чтобы промежуточное ПО wrap-cookies правильно обрабатывало его, чтобы оно работало.

3.3. Обработчики

Теперь, когда мы понимаем запросы и ответы, мы можем начать писать нашу функцию-обработчик, чтобы связать их вместе.

Обработчик — это простая функция, которая принимает входящий запрос в качестве параметра и возвращает исходящий ответ . То, что мы делаем в этой функции, полностью зависит от нашего приложения, если оно соответствует этому контракту.

В самом простом случае мы могли бы написать функцию, которая всегда возвращает один и тот же ответ:

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

Мы также можем взаимодействовать с запросом по мере необходимости.

Например, мы могли бы написать обработчик для возврата входящего IP-адреса:

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

3.4. ПО промежуточного слоя

Промежуточное ПО — это название, распространенное в некоторых языках, но в меньшей степени в мире Java . Концептуально они похожи на фильтры сервлетов и перехватчики Spring.

В Ring промежуточное ПО относится к простым функциям, которые обертывают основной обработчик и каким-то образом настраивают некоторые его аспекты. Это может означать изменение входящего запроса до его обработки, изменение исходящего ответа после того, как он сгенерирован, или, возможно, ничего не делать, кроме регистрации того, сколько времени потребовалось для обработки.

Как правило, промежуточные функции принимают первый параметр обработчика для переноса и возвращают новую функцию-обработчик с новой функциональностью .

Промежуточное ПО может использовать любое количество других параметров . Например, мы могли бы использовать следующее, чтобы установить заголовок Content-Type для каждого ответа от обернутого обработчика:

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

Читая его, мы видим, что мы возвращаем функцию, которая принимает запрос — это новый обработчик. Затем это вызовет предоставленный обработчик, а затем вернет измененную версию ответа.

Мы можем использовать это для создания нового обработчика, просто соединив их вместе:

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

Clojure также предлагает более естественный способ связывания многих из них — с помощью макросов Threading Macros . Это способ предоставить список функций для вызова, каждая из которых имеет вывод предыдущей.

В частности, нам нужен макрос Thread First -> . Это позволит нам вызывать каждое промежуточное ПО с предоставленным значением в качестве первого параметра:

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

Затем был создан обработчик, который является исходным обработчиком, обернутым тремя различными функциями промежуточного программного обеспечения.

4. Написание обработчиков

Теперь, когда мы понимаем компоненты, из которых состоит приложение Ring, нам нужно знать, что мы можем делать с реальными обработчиками. Это сердце всего приложения, и именно здесь будет выполняться большая часть бизнес-логики.

Мы можем поместить в эти обработчики любой код, который пожелаем, включая доступ к базе данных или вызов других служб. Ring дает нам некоторые дополнительные возможности для работы напрямую с входящими запросами или исходящими ответами, которые также очень полезны.

4.1. Обслуживание статических ресурсов

Одной из самых простых функций, которые может выполнять любое веб-приложение, является обслуживание статических ресурсов. Для упрощения этой задачи Ring предоставляет две промежуточные функции — wrap-file и wrap-resource .

Промежуточное программное обеспечение Wrap-file занимает каталог в файловой системе . Если входящий запрос соответствует файлу в этом каталоге, то этот файл возвращается вместо вызова функции обработчика:

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

Похожим образом промежуточное ПО wrap-resource использует префикс пути к классам, в котором оно ищет файлы :

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

В обоих случаях функция обернутого обработчика вызывается только в том случае, если не найден файл для возврата клиенту .

Ring также предоставляет дополнительное промежуточное программное обеспечение, чтобы упростить их использование через 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)

Промежуточное ПО wrap-content-type автоматически определит заголовок Content-Type , который нужно установить, на основе запрошенного расширения имени файла. По промежуточного слоя wrap-not-modified сравнивает заголовок If-Not-Modified со значением Last-Modified для поддержки кэширования HTTP, возвращая файл только в случае необходимости.

4.2. Доступ к параметрам запроса

При обработке запроса существует несколько важных способов, которыми клиент может предоставить информацию серверу. К ним относятся параметры строки запроса, включенные в URL-адрес и параметры формы, которые отправляются в качестве полезной нагрузки для запросов POST и PUT.

Прежде чем мы сможем использовать параметры, мы должны использовать промежуточное ПО wrap-params для переноса обработчика . Это корректно анализирует параметры, поддерживающие кодировку URL, и делает их доступными для запроса. Это может дополнительно указать используемую кодировку символов, по умолчанию UTF-8, если она не указана:

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

После этого запрос будет обновлен, чтобы сделать параметры доступными . Они входят в соответствующие ключи во входящем запросе:

  • :query-params — параметры, извлеченные из строки запроса.
  • :form-params — параметры, извлеченные из тела формы
  • :params — комбинация :query-params и :form-params

Мы можем использовать это в нашем обработчике запросов точно так, как ожидалось.

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

Этот обработчик вернет ответ, содержащий значение параметра input .

Параметры сопоставляются с одной строкой, если присутствует только одно значение, или со списком, если присутствует несколько значений .

Например, мы получаем следующие карты параметров:

// /echo?input=hello
{"input "hello"}

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

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

4.3. Получение загруженных файлов

Часто мы хотим иметь возможность писать веб-приложения, в которые пользователи могут загружать файлы. В протоколе HTTP это обычно обрабатывается с помощью составных запросов. Это позволяет одному запросу содержать как параметры формы, так и набор файлов.

Ring поставляется с промежуточным программным обеспечением, называемым wrap-multipart-params , для обработки такого рода запросов. Это похоже на то, как wrap-params анализирует простые запросы.

wrap-multipart-params автоматически декодирует и сохраняет любые загруженные файлы в файловую систему и сообщает обработчику, где они находятся, чтобы он мог с ними работать:

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

По умолчанию загруженные файлы сохраняются во временном системном каталоге и автоматически удаляются через час . Обратите внимание, что это требует, чтобы JVM все еще работала в течение следующего часа для выполнения очистки.

При желании, есть также хранилище в памяти , хотя, очевидно, это может привести к нехватке памяти, если будут загружены большие файлы.

Мы также можем написать свои механизмы хранения, если это необходимо, если они соответствуют требованиям API.

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

После настройки этого промежуточного программного обеспечения загруженные файлы становятся доступными в объекте входящего запроса по ключу params . Это то же самое, что и промежуточное ПО wrap-params . Эта запись представляет собой карту, содержащую детали, необходимые для работы с файлом, в зависимости от используемого хранилища.

Например, хранилище временных файлов по умолчанию возвращает значения:

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

Где запись :tempfile является объектом java.io.File , который непосредственно представляет файл в файловой системе.

Файлы cookie — это механизм, при котором сервер может предоставить небольшой объем данных, которые клиент будет продолжать отправлять обратно при последующих запросах . Обычно это используется для идентификаторов сеансов, токенов доступа или постоянных пользовательских данных, таких как настроенные параметры локализации.

Ring имеет промежуточное программное обеспечение, которое позволит нам легко работать с файлами cookie . Это позволит автоматически анализировать файлы cookie для входящих запросов, а также позволит нам создавать новые файлы cookie для исходящих ответов .

Настройка этого промежуточного программного обеспечения выполняется по тем же шаблонам, что и раньше:

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

На этом этапе файлы cookie всех входящих запросов будут проанализированы и помещены в ключ :cookies в запросе . Это будет содержать карту имени и значения файла cookie:

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

Затем мы можем добавить файлы cookie к исходящим ответам, добавив ключ :cookies к исходящему ответу . Мы можем сделать это, создав ответ напрямую:

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

Также есть вспомогательная функция, которую мы можем использовать для добавления файлов cookie к ответам аналогично тому, как раньше мы могли устанавливать коды состояния или заголовки:

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

Для файлов cookie также могут быть установлены дополнительные параметры , необходимые для спецификации HTTP. Если мы используем set-cookie , мы предоставляем их в качестве параметра карты после ключа и значения. Ключи к этой карте:

  • :domain – Домен, которым ограничивается cookie
  • :path — путь, которым ограничивается cookie
  • :securetrue , чтобы отправлять куки только при HTTPS-соединениях.
  • :http-onlytrue , чтобы сделать cookie недоступным для JavaScript.
  • :max-age – количество секунд, по истечении которых браузер удаляет куки.
  • :expires — конкретная метка времени, после которой браузер удаляет файл cookie.
  • :same-site — если установлено значение :strict , браузер не будет отправлять этот файл cookie обратно с межсайтовыми запросами.
(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. Сессии

Файлы cookie дают нам возможность хранить биты информации, которую клиент отправляет обратно на сервер при каждом запросе. Более мощный способ добиться этого — использовать сеансы. Они полностью хранятся на сервере, но клиент поддерживает идентификатор, который определяет, какой сеанс использовать.

Как и все остальное здесь, сессии реализуются с помощью промежуточной функции :

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

По умолчанию это сохраняет данные сеанса в памяти . Мы можем изменить это, если необходимо, и Ring поставляется с альтернативным хранилищем, которое использует файлы cookie для хранения всех данных сеанса.

Как и в случае с загрузкой файлов, при необходимости мы можем предоставить функцию хранения .

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

Мы также можем настроить детали файла cookie, используемого для хранения сеансового ключа .

Например, чтобы сделать так, чтобы файл cookie сеанса сохранялся в течение одного часа, мы могли бы сделать:

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

Атрибуты cookie здесь такие же, как и в промежуточном программном обеспечении wrap-cookies .

Сеансы часто могут выступать в качестве хранилищ данных для работы. Это не всегда хорошо работает в модели функционального программирования, поэтому Ring реализует их немного по-другому.

Вместо этого мы получаем доступ к данным сеанса из запроса и возвращаем карту данных для сохранения в ней как часть ответа . Это все состояние сеанса для сохранения, а не только измененные значения.

Например, следующее ведет текущий подсчет того, сколько раз был запрошен обработчик:

(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))))

Работая таким образом, мы можем удалить данные из сеанса, просто не включая ключ . Мы также можем удалить весь сеанс, вернув nil для новой карты.

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

5. Плагин Лейнинген

Ring предоставляет плагин для инструмента сборки Leiningen, который помогает как в разработке, так и в производстве.

Мы настраиваем плагин, добавляя правильные данные плагина в файл project.clj :

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

Важно, чтобы версия lein-ring соответствовала версии Ring . Здесь мы использовали Ring 1.7.1, а это значит, что нам нужен lein-ring 0.12.5. В общем, безопаснее просто использовать последнюю версию обоих, как показано в Maven Central или с помощью команды lein search :

$ 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

Параметр :handler вызова :ring — это полное имя обработчика, который мы хотим использовать. Это может включать любое промежуточное ПО, которое мы определили.

Использование этого плагина означает, что нам больше не нужна основная функция . Мы можем использовать Leiningen для запуска в режиме разработки, или же мы можем создать производственный артефакт для целей развертывания. Теперь наш код сводится именно к нашей логике и ничего более .

5.1. Создание производственного артефакта

После того, как это настроено, теперь мы можем создать файл WAR, который мы можем развернуть в любом стандартном контейнере сервлетов :

$ 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

Мы также можем создать отдельный файл JAR, который будет запускать наш обработчик точно так, как ожидалось :

$ 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

Этот файл JAR будет включать основной класс, который запустит обработчик во встроенном контейнере, который мы включили . Это также будет учитывать переменную среды PORT , позволяющую нам легко запускать ее в производственной среде:

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 ServerConnector@44a6a68e{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. Запуск в режиме разработки

В целях разработки мы можем запускать обработчик прямо из Leiningen без необходимости создавать и запускать его вручную . Это упрощает тестирование нашего приложения в реальном браузере:

$ 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 ServerConnector@69886d75{HTTP/1.1,[http/1.1]}{0.0.0.0:3000}
2019-04-12 07:16:29.092:INFO:oejs.Server:main: Started @1587ms

Это также учитывает переменную среды PORT , если мы ее установили.

Кроме того, есть библиотека Ring Development, которую мы можем добавить в наш проект . Если это доступно, сервер разработки попытается автоматически перезагрузить любые обнаруженные изменения исходного кода . Это может дать нам эффективный рабочий процесс изменения кода и просмотра его в реальном времени в нашем браузере. Это требует добавления зависимости Ring-Devel :

[ring/ring-devel "1.7.1"]

6. Заключение

В этой статье мы дали краткое введение в библиотеку Ring как средство для написания веб-приложений на Clojure. Почему бы не попробовать это в следующем проекте?

Примеры некоторых рассмотренных здесь концепций можно увидеть на GitHub .