(This is my attempt to compose a book about Clojure. I decided to start with a web development section to see how far I could go. It’s in Russian because here Clojure isn’t popular and its popularity across developers is low. I hope I’ll translate this in English one day.)

Содержание

В этой главе мы рассмотрим азы разработки на Clojure под веб-платформу. Поговорим о том, как устроен протокол HTTP и как передавать по нему данные. Рассмотрим, какие абстракции использует Clojure над протоколом, чтобы сделать разработку быстрой и удобной.

Каждый год компания Cognitect опрашивает Clojure-разработчиков. Среди прочих вопросов встречается о том, в какой области вы работаете? В 2010 году веб-разработкой занимались 50% опрошенных, то есть каждый второй. К 2018 году эта цифра выросла до 82%. Это уже четыре человека из пяти.

Развитие веба не связано напрямую с Clojure. Похожую динамику показывают ежегодные опросы StackOverflow. Согласно им, все больше разработчиков переходит из смежных областей в веб.

Справедливо утверждать, что если вы найдете работу на Clojure, то скорее всего это будет веб-приложение. Речь необязательно идет о сайте компании. Иногда такие сайты даже бывают статичными, то есть состоят из набора HTML-файлов. Но веб-приложение — это не только текст с картинками. В общем значении это передача и обработка данных по протоколу HTTP.

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

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

Основы HTTP

HTTP это протокол, который работает поверх TCP/IP. Протокол в широком смысле — это соглашение о том, в каком порядке передавать данные. Протоколы обычно зафиксированы в официальных документах. Для HTTP такой документ называется RFC 2616. Разработчики браузеров и фреймворков должны сверяться с этим документом во время работы.

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

Различают HTTP-запрос и ответ. Оба состоят из трех частей: первая строка, заголовки и тело.

Первая (или стартовая) строка содержит самую важную информацию о запросе или ответе. Формат строки различается для запроса и ответа. Для запроса это метод, путь и версия, для ответа — статус, сообщение и версия.

Заголовки — это пары ключ-значение. В современных фреймворках они, как правило, выражены словарем. Заголовки содержат дополнительные сведения о запросе или ответе. Например, заголовок Content-Type сообщает, как следует трактовать тело запроса. Был ли это XML- или JSON-документ? Программа проверяет заголовок и читает содержимое должным образом.

После заголовков следует тело. Телом может быть что угодно — текст, данные в виде “поле=значение”, JSON-документ, картинка, фильм, электронное письмо. Стандарт предусматривает смешанный тип, т.н. multipart-encoding. Тело такого запроса раздроблено на ячейки, в каждом из которых живет свое содержимое. Например, текст, картинка, снова текст, двоичный файл.

Несколько примеров HTTP-запросов и ответов. Именно в таком виде они передаются по сети. Это запрос к главной странице Google, поисковой терм — сlojure:

GET /search?q=clojure HTTP/1.1
Host: google.com
Accept-Language: en-us
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)
(blank line)

Пример POST-запроса с передачей JSON-документа:

POST /api/users/ HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)

{
  "username": "John",
  "city": "NY"
}

Ответ на такой запрос:

HTTP/1.1 200 OK
Date: Tue, 19 Mar 2019 15:57:11 GMT
Server: Nginx
Connection: close
Content-Type: application/json

{
  "code": "CREATED",
  "message": "A user has been created successfully"
}

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

Рассмотрим сценарий: в запросе указаны метод и путь GET /about, но такой страницы не существует. Сервер может проверить это заранее, например, сверив путь с конфигурацией маршрутов. Когда маршрута нет, сервер вернет ответ со статусом 404. Не придется читать тело запроса, что существенно ускорит работу сервера.

Получив ответ, клиент прочитает статус 404 из первой строки. Такой ответ трактуется как ошибочный. Логика клиента может быть такова, что в случае ошибки читать тело ответа не нужно. Это облегчит работу клиента.

Чтение и разбор содержимого это дорогая операция. Современные фреймворки пытаются исключить случаи, когда чтение происходит зря. Например, по заголовку Content-Type мы определяем, стоит ли читать тело. Наше приложение работает только с JSON, поэтому для значения text/xml вернем ошибку. Аналогично с заголовком Content-Length, где содержится длина тела в байтах. Если значение больше заданного лимита, сервер отклонит запрос до чтения тела.

Центральные параметры запроса это метод и путь. Путь указывает на определенный ресурс на сервере. Иногда сервер трактует путь как файл относительно заданной директории. Например, /images/map.jpg означает вернуть такой файл из директории /var/www/static. Но чаще всего приложение обрабатывает путь согласно внутренней логике. Ответом приложения может быть не только файл, но и js-скрипт, HTML-разметка или JSON-документ.

Метод запроса означает действие, которые мы намерены выполнить над ресурсом. Основные методы это GET, POST, PUT и DELETE. Их семантика в том же порядке — прочитать, создать, обновить, удалить ресурс. Так, запрос POST /users/ означает создать пользователя, а GET /users/1 — чтение пользователя под номером 1.

Главный параметр ответа это статус — целое положительное число. Статусы группируют по старшему разряду. Значения с 200 до 299 (или 2хх) считаются положительными. Они означают, что сервер успешно обработал запрос.

Значения в группе 3хх связаны с перенаправлением на другую страницу. Как правило, в заголовке Location сервер сообщает путь, по которому следует обратиться. Современные браузеры и HTTP-клиенты достаточно умны, чтобы автоматически послать второй запрос по новому адресу. Так, при запросе страницы http://yandex.ru вы получите пустой документ с заголовком Location: https://yandex.ru (безопасное соединение). Но браузер переключит страницу сам.

Статусы из группы 4хх означают ошибку на стороне клиента. Чаще всего это 404 — страница не найдена. На ошибочные данные сервер отвечает 400 — Bad request. Когда нет прав на просмотр документа, клиент получит код 403.

Статусы из группы 5хх сигнализируют об ошибке на стороне сервера или полной его недоступности. Это деление на ноль, недоступность базы данных, недостаток места на диске.

Принято считать, что ответ со статусом вне диапазона 2хх означает ошибку. Большинство HTTP-клиентов запрограммированы на выброс исключения в таких случаях. Строго говоря, это верно только на высоком, абстрактном уровне. С точки зрения протокола ответ 404 Not Found такой же правильный, как и 200 OK.

Дополнительные операции над ресурсом используют другие, более редкие методы. Например, HEAD — получить только краткие сведения об объекте. Сервис Amazon S3 в ответ на HEAD-запрос отдает только статус и заголовки. В них указаны тип файла и размер, контрольная сумма, дата последнего изменения. В данном случае HEAD-запрос предпочтительней GET. Метаданные могут храниться в особом хранилище отдельно от файла. Доступ к такому хранилищу обычно быстрее, чем к файлу на диске.

Подход “метод-ресурс” со временем вырос в то, что сегодня называется REST. Последователи REST выделяют бизнес-сущности и CRUD-операции над ними (Create, Read, Update, Delete). Считается хорошим подход, когда сущность определяется через путь, например /users/1, а операция — методом. Если это создание или изменение сущности, данные читаются из тела, обычно JSON-документа. Мы не будем задерживаться на REST, потому что это всего лишь свод рекомендаций, не идеальный и не единственный.

Отметим, что HTTP не предусматривает строгое соблюдение этих рекомендаций. Разработчик вправе обрабатывать запросы и ответы так, как удобно в данном случае. Например, принимать только POST-запросы с данными в теле. Или только GET с параметрами из строки запроса. Верную стратегию определяют бизнес, инструменты или потребители сервиса.

Возвращаясь к Clojure

Современные фреймворки строят абстракции над HTTP-протоколом. Разработчику не требуется читать данные из сокета и выполнять разбор запроса, ровно как и писать в сокет байты ответа. Эту задачу берет на себя фреймворк и сервер.

Взамен разработчик получает набор классов, чтобы с их помощью выразить бизнес-логику приложения. Типичный веб-проект на Python или Java это комбинация нескольких классов. Как правило, это Application — главная сущность проекта. Класс Router определяет, на какой обработчик переключить входящий запрос — Request. Обработчик — это класс Handler с методами .onGet, .onPost и тд. Ожидается, что он вернет экземпляр класса Response.

По такому принципу устроены все промышленные веб-фреймворки: Django, Rails, Symfony. Названия классов и их композиция различаются, но суть остается прежней. Это приложение, маршрутизатор, обработчик, запрос и ответ. Проблема в том, что каждый фремворк моделирует собственные классы, которые несовместимы между собой в рамках языка.

Рассмотрим язык Python и фреймворки Django и Flask. Оба следуют той же структуре. Так, запрос в Django представлен классом django.http.HttpRequest, а во Flask — flask.Request. Даже беглого взгляда достаточно, чтобы увидеть, насколько они отличаются. У классов разные методы и поля. То, что есть в первом классе, отсутствует во втором. Использовать flask.Request в проекте на Django не представляется возможным.

Это приводит к тому, что проект увязает в архитектуре фреймворка. Переезд на другой фремворк обходится с трудом. Хорошие практики предписывают разделять проект на слои. Слой транспорта отвечает за ввод и вывод данных по протоколу HTTP. Слой логики исполняет внутренний код, ничего не зная о HTTP. Согласно такому принципу, логика не зависит от транспорта, и последний может быть изменен в любой момент. Но на практике это справедливо не всегда. Порой мы вынуждены сделать слишком много за короткое время. Нам не дают исправить поспешные решения, и плохой код остается в проекте навсегда.

В Clojure другой подход.

Разработчик Джеймс Ривз (James Reeves) известен своим вкладом в экосистему Clojure. Он разработал 60 библиотек для самых разных задач. Нет такого проекта на Clojure, который бы не использовал его наработки.

Заслуга Джеймса в том, что он стандартизировал веб-разработку для Clojure на заре этого языка. Вместо того, чтобы писать фремворк под сиюминутные нужды, он придумал, как сделать удобно всем.

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

Скептики могут заметить, что мысль не нова. Действительно, в том же Django обработчик запроса может быть не классом, а функцией. Разница в том, что отдельный обработчик — это еще не приложение. Ему не хватает маршрутизатора, middleware и других абстракций. Поэтому в мире Django или Flask выразить обработчик функцией — всего лишь приятная возможность фреймворка.

Но Clojure устроена так, что маршрутизатор — это тоже функция, которая принимает запрос, определяет нужный обработчик и возвращает ответ. Middleware это тоже функция, которая принимает функцию-обработчик и возвращают новый обработчик с дополненной логикой. Каждую тяжелую абстракцию (классы Application, Router, Handler) в мире Clojure принято заменять функцией. Это удобно, потому что в отличии от классов функции компонуются.

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

Спецификация упрощает разработку и переносимость кода. Два веб-проекта на Clojure обязаны принимать и отдавать одинаковые структуры данных. Разработчик очередного фреймворка должен учитывать спецификацию. Если фреймворк следует стандартам, проще привлечь на свою сторону сообщество.

Описанные выше идеи выражены в проекте Ring. Сегодня это стандарт веб-разработки в Clojure. Репозиторий содержит спецификацию запроса и ответа и базовый код для обработки этих структур. Плюс основные middleware, запуск Jetty-сервера и документация. Удивительно, как мало кода понадобилось проекту, чтобы попасть на компьютер каждому Clojure-разработчику.

Со временем появился термин “Ring-совместимость”. Его придерживаются все современные Clojure-фреймворки. Типичное Ring-приложение запускается на многих платформах: Jetty, Netty, Immutant и др. без изменений в коде.

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

  • ring-core — базовая функциональность: параметры, разбор тела, куки, сессии, и тд;
  • ring-jetty-adapter — запуск полноценного веб-сервера из функции-приложения.

Свое первое веб-приложение вы напишете даже без библиотеки. Вот оно:

(defn app
  [request]
  (let [{:keys [uri request-method]} request]
    {:status 200
     :headers {"Content-Type" "text/plain"}
     :body (format "You requested %s %s"
                   (name request-method)
                   uri)}))

Приложение извлекает путь и метод из запроса и формирует ответ. Его статус положительный — 200. Мы выставили один заголовок с типом документа “простой текст”. Поле :body содержит строку, которую мы построили функцией format.

Поскольку app это функция, вызовем ее с различными запросами:

(app {:request-method :get :uri "/index.html"})
{:status 200,
 :headers {"Content-Type" "text/plain"},
 :body "You requested get /index.html"}

(app {:request-method :post :uri "/users"})
{:status 200,
 :headers {"Content-Type" "text/plain"},
 :body "You requested post /users"}

Работает. Но пока что это структуры данных, и не ясно, что будет в браузере. Запустим приложение как HTTP-сервер.

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

Добавим в проект зависимости:

[ring/ring-core "1.7.1"]
[ring/ring-jetty-adapter "1.7.1"]

Пример запуска сервера:

(require '[ring.adapter.jetty :refer [run-jetty]])
(run-jetty app {:port 8080 :join? true})

Здесь происходит следующее. Мы импортировали в текущее пространство функцию run-jetty. Она принимает два параметра — функцию-приложение и словарь параметров. Опция join? определяет, будет ли заблокирован текущий тред до конца работы сервера. Если передать false, сервер будет запущен в фоне. Чтобы остановить, нужно сохранить его в переменную и вызвать метод .stop:

(def server
  (run-jetty app {:port 8080
                  :join? false}))

;; after a while
(.stop server)

Если флаг был true, как в первом случае, то главный поток повиснет до конца работы сервера. Придется либо завершить программу, либо нажать Ctrl-C.

Во время работы сервера откройте браузер по адресу http://127.0.0.1:8080/. Вы увидите строку из примера выше. Укажите произвольный путь, например /hello, /path/to/file.txt. Ответ сервера изменится.

Подробней о запросах и ответах

В предыдущем примере мы написали приложение, которые печатает метод и путь запроса. Это важные, но не единственные его поля. Запрос содержит порт и адрес сервера, строку запроса, тип протокола, заголовки и тело. Уточним, что запрос это неизменяемый словарь, ключи которого keywords (кейворды или ключевые слова, в других языках — теги). Полная спецификация запроса и ответа лежит в репозитории на Гитхабе.

Обратим внимание на поля :headers и :body.

Заголовки это неизменяемый словарь, но его ключи не кейворды, а строки. Такой словарь не работает с destructuring assignment. В примере ниже host получит значение nil:

(defn some-handler
  [request]
  (let [{:keys [headers]} request
        {:keys [host]} headers]
    ...))

Чтобы извлечь заголовки правильно, используйте get со строкой:

(get headers "host")
127.0.0.1

Заметим, что имя заголовка всегда в нижнем регистре. С точки зрения HTTP, оба написания Content-Type и content-type верны. Сервер принудительно сводит заголовки к нижнему регистру, чтобы избежать неоднозначности.

Значения заголовков тоже строки. Даже если стандарт HTTP определяет типы некоторых заголовков, Ring не пытается вывести их. Например, заголовок Content-Length передает длину тела в байтах. Современные фреймворки приводят его к числу и помещают в отдельное поле запроса. По умолчанию Ring не делает чего-то подобного, но такой функционал легко добавить.

За ключами-строками стоит проблема. Clojure спроектирована так, что почти всегда ключи словаря это кейворды. Легко забыть о том, что у заголовков они строки. Так появляются ошибки, когда разработчик деструктурирует заголовки и получает nil.

Можно обработать словарь заголовков, заменив тип ключей. Для одного случая это нормально. Но если так делает каждый обработчик, это плохая идея. Правильно сделать так, чтобы каждая функция получала запрос с уже исправленными заголовками. Эта техника называется Middleware, и мы рассмотрим ее ниже.

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

Обратите внимание на тип body. Это не строка, а входящий поток — java.io.InputStream. Поток — это источник данных, который можно прочесть только раз. По умолчанию Ring не читает поток. Это остается на усмотрение разработчика.

Вспомним, что чтение и разбор тела это сложная и небезопасная операция. По заголовкам следует определить тип документа и его длину, прочитать нужное число байт и восстановить документ в структуру (JSON, XML, etc). Результат каждого шага следует проверять по разным критериям. Чтобы получить из Content-Length число, мы должны быть готовы к исключению во время разбора строки. Но результат -42 тоже неверный, потому что число байт в потоке не может быть отрицательным.

Технически возможно послать серверу JSON-документ, но указать Content-Type: text/xml. Тот, кто это сделал, не обязательно злоумышленник. Это может быть ошибка в коде на стороне клиента. Сервер должен быть готов к подобному сценарию.

Легче всего считать тело в строку функцией slurp:

(defn handler
  [request]
  (when-let [content
             (some-> request :body slurp)]
    (process-content content))
  {:status 200})

Но в современном вебе уже не работают с текстом. Мы работаем с данными — словарями и объектами. Позже рассмотрим, как Ring переводит байты в данные и наоборот.

Структура ответа

Ответ Ring устроен проще. Это неизменяемый словарь, в котором только три поля: :status, :headers и :body.

  • :status — целое положительное число. От статуса зависит успех запроса. Мы рассмотрели семантику статуса в начале главы.

  • :headers — заголовки ответа. В отличии от заголовков запроса, ключи и значения не обязательно строки. Вариант ниже корректен:

{:status 302
 :headers {:content-length 0
           :location "/new/page.html"}}

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

Маршрутизация

Мы запустили приложение и наблюдали его из браузера. На любой запрос оно выдает текст с незначительными отличиями. Это проблема. Невозможно поддерживать приложение, в котором все запросы сходятся в одну точку. Правильно будет написать отдельный обработчик для каждой бизнес-задачи. Затем как-то распределить по ним входящие запросы. Это называется маршрутизатор или роутинг.

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

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

Рассмотрим тривиальный случай. Вообразим, что адресу “/” мы бы хотели видеть название сайта, а по “/hello” — приветствие. Все другие адреса возвращают 404 Page not found. Определим обработчики:

(defn page-index
  [request]
  {:status 200
   :headers {:content-type "text/plain"}
   :body "Learning Web for Clojure"})

(defn page-hello
  [request]
  {:status 200
   :headers {:content-type "text/plain"}
   :body "Hi there and keep trying!"})

(defn page-404
  [request]
  {:status 404
   :headers {:content-type "text/plain"}
   :body "No such a page."})

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

Наивный подход

Сделаем самое простое, что приходит в голову. Напишем обработчик, который вручную определяет нужный маршрут. Достаточно проверить путь:

(defn app
  [request]
  (let [{:keys [uri]} request]
    (case uri
      "/"      (page-index request)
      "/hello" (page-hello request)
      (page-404 request))))

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

Недостатки этой функции очевидны. Мы не учитываем метод запроса. “GET /users” и “POST /users” различны по смыслу. Наша реализация сравнивает пути в лоб без учета их параметров. С точки зрения правильного роутинга запросы “GET /users/1” и “GET /users/99” сходятся в один обработчик, но с разным параметром id.

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

Эти и другие проблемы решены в отдельных Clojure-библиотеках. Мы рассмотрим две из них: Compojure и Bidi. Каждая библиотека решает задачу роутинга по-своему, их подходы ортогональны.

Compojure

Библиотека Compojure предлагает макросы для описания маршрутов. Макросы устроены так, что их набор похож на таблицу правил. Добавим зависимость в проект:

[compojure "1.6.1"]

Вот как выглядит приложение на Compojure:

(require '[compojure.core
           :refer [GET defroutes]])

(defroutes app
  (GET "/"      request (page-index request))
  (GET "/hello" request (page-hello request))
  page-404)

Это гораздо лучше той каши, что мы написали вначале.

Разберемся, что получили на выходе. Переменная app — это функция, которая принимает запрос. Обратим внимание, что app объявлена не через def или defn, а особенным макросом. Мы поговорим о макросах в отдельной главе. Пока что скажем, что defroutes делает две вещи: создает функцию-роутер и связывает ее с переменной через defn. Это обвязка, чтобы писать меньше кода.

Макрос принимает набор правил. Правило это форма вида (метод, путь, запрос, выражение). Первые два правила созданы макросом GET. Читать их следует так: если метод запроса GET и путь “/”, то для запроса request верни (page-index request).

Правило компилируется в функцию, которая принимает запрос. В начале работы такая функция проверяет, действительно ли метод и путь запроса совпадают с заданными. Если да, то функция вычислит выражение и вернет его результат, в нашем случае (page-index request).

Если запрос не удовлетворяет критериям, то правило-функция вернет nil. Это значит, что следует попробовать следующее правило, и так далее. Макрос defroutes автоматизирует эти действия. Он оборачивает правила в особый цикл. На каждом шаге макрос берет очередное правило, применяет к нему запрос и оценивает результат. Первое отличное от nil значение станет ответом к текущему запросу.

Что будет, если не подошло ни одно правило? Такое вполне возможно. Тогда приложение вернет nil, и это вызовет ошибку на уровне сервера. Nil не может быть ответом на запрос, потому что не ясен его смысл.

Чтобы избежать nil, в конец правил добавляют еще одно, такое, что вернет правильный ответ независимо от запроса. В нашем случае это функция page-404. Ее результат всегда одинаков. Так мы гарантируем, что даже если запрос не подошел первым двум правилам, последнее сработает обязательно.

Так работает роутинг на Compojure. Мы пишем обработчики запросов в отдельных модулях. Затем импортируем их в модуль с роутингом. С помощью макросов GET, POST и т.д. мы оборачиваем их в правила. Правило возвращает функцию, которая проверяет, что запрос соответствует критериям. Если да, то результатом будет вызов обработчика с запросом.

Продвинутые возможности

Выше мы обозначили проблему: правила “GET /users/1” и “GET /users/99” это один и тот же обработчик, но с параметром. Вот как описать такой путь:

(GET "/users/:id" [id :as request] (page-user request))

Обратите внимание, в пути двоеточие перед id, а третий параметр заключен в квадратные скобки. Такой синтаксис означает, что часть с двоеточием следует трактовать как параметр. Compojure поместит его в поле запроса params. Обработчик page-user должен извлечь его следующим образом:

(defn page-user
  [request]
  (when-let [user-id (-> request :params :id)]
    (let [user (get-user-by-id user-id)
          {:keys [fname lname]} user]
      {:status 200
       :body (format "User %s is %s %s"
                     user-id fname lname)})))

В данном случае предположим, что функция get-user-by-id возвращает словарь пользователя по его номеру. Из словаря мы извлекаем имя и фамилию, формируем строку и возвращаем ответ.

Compojure решает проблему вложенных путей. Предположим, приложение показывает и редактирует товары. По адресу “/content/order/1/view” открывается карточка товара для просмотра. Страница “/content/order/1/edit” выводит форму редактирования этого товара. Чтобы сохранить товар, нужно отправить поля формы по тому же пути, но методом POST.

Очевидно, правила пересекаются. Чтобы избежать повторов, используем макрос context:

(context "/content/order/:id" [order-id]
  (GET  "/view" request (order-view request))
  (context "/edit" []
    (GET  "/" request (order-form request))
    (POST "/" request (order-save request))))

Каждое правило под макросом context наследует параметры запроса. Это значит, обработчики order-view, order-form и order-save получат параметр :order-id из :params.

До сих пор в качестве выражения в правилах мы указывали что-то вроде (some-handler request). Бывает, что ответ по данному пути заранее известен, поэтому нет смысла выносить его в отдельную функцию. Пусть выражение будет готовым ответом. Рассмотрим это на примере healthcheck-обработчика.

Современные приложения часто запускают в контейнерах и облачных сервисах. Чтобы узнать, работает приложение или нет, специальная служба периодически опрашивает его. Стандартный способ сделать это — послать приложению GET-запрос по адресу “/health” и проверить статус. Тело и заголовки ответа не играют роли.

Чтобы не создавать лишний обработчик (page-health request), поместим ответ в тело:

(ANY "/health" _ {:status 200 :body "ok"})

Однако, можно сделать еще проще. В Compojure предусмотрен случай, когда выражение это строка. Compojure трактует такую строку как тело положительного ответа:

(ANY "/health" _ "ok")

Роутинг с Bidi

Библиотека Bidi решает проблему роутинга иным способом. Compojure предлагает макросы, чтобы описать правила и сделать по ним перебор. Bidi опирается на данные — списки и словари. Сценарий роутинга в Bidi состоит из нескольких шагов.

На первом этапе объявить особое дерево маршрутов. Это дерево — комбинация векторов и словарей по определенным правилам. В листьях дерева поместить теги — уникальные метки для обозначения листа. Особая функция принимает это дерево и запрос. Функция пытается понять, на какую ветвь дерева ложиться запрос. Если таковая нашлась, результатом будет тег ветки и, возможно, параметры пути. Например, {:route :show-user, :route-params: {:id 1}}.

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

На третьем этапе — объявить обработчик запроса. Но это будет не функция, а мультиметод. Его функция-диспачер возвращает тег. Метод :default возвращает ответ 404, :show-user — страницу пользователя, и так далее.

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

Перепишем на Bidi все то, что сделали на Compojure. Добавьте в проект зависимость:

[bidi "2.1.5"]

Начнем с дерева маршрутов. Вариант с page-index, page-hello и page-404 будет выглядеть так:

(def routes
  ["/" {""      :page-index
        "hello" :page-hello
        true    :not-found}])

Проверим, как работает матчинг пути по этому дереву. Функция match-route принимает маршруты и путь и возвращает словарь с тегом:

(require '[bidi.bidi :as bidi])

(bidi/match-route routes "/hello")
{:handler :page-hello}

(bidi/match-route routes "/test")
{:handler :not-found}

Ответ функции следует объединить со словарем запроса. Чтобы сделать это за один шаг, воспользуемся функцией match-route*. Это альтернативная версия match-route, которая принимает словарь-накопитель.

(let [request
      {:request-method :get
       :uri "/test"}]
  (bidi/match-route* routes (:uri request) request))

{:request-method :get
 :uri "/test"
 :handler :not-found}

Видим, что match-route* вернула переданный запрос, но добавила в него поле handler. Перенесем код выше в middleware. Это функция, которая принимает обработчик запроса и возвращает его альтернативную версию. Такой обработчик, получив запрос, сперва добавит к нему поле handler и вызовет исходный обработчик с новым запросом.

(defn wrap-handler
  [handler]
  (fn [request]
    (let [{:keys [uri]} request
          request* (bidi/match-route*
                    routes uri request)]
      (handler request*))))

Мы еще не касались техники middleware, но вынуждены применить ее на данном этапе. Ниже мы рассмотрим во деталях, как устроены middleware и почему так важны.

Проверим wrap-handler на скорую руку. Будем считать, что обработчик запроса это стандартная функция identity. Она всегда возвращает переданный в нее аргумент:

((wrap-handler identity)
 {:request-method :get
  :uri "/hello?foo=42"})

{:request-method :get,
 :uri "/hello?foo=42",
 :handler :page-hello}

Конечный обработчик запроса будет мультиметодом. Его функция-диспатчер просто :handler.

(defmulti multi-handler
  :handler)

(defmethod multi-handler :page-index
  [request]
  {:status 200
   :headers {:content-type "text/plain"}
   :body "Learning Web for Clojure"})

(defmethod multi-handler :page-hello
  [request]
  {:status 200
   :headers {:content-type "text/plain"}
   :body "Learning Web for Clojure"})

(defmethod multi-handler :not-found
  [request]
  {:status 404
   :headers {:content-type "text/plain"}
   :body "No such a page."})

Теперь обернем multi-handler в middleware. Это и будет финальное приложение.

(def app
  (wrap-handler multi-handler))

Запустите веб-сервер и проверьте результат в браузере.

Это был простой вариант роутинга на Bidi. Рассмотрим пример с заказами: просмотр, редактирование и сохранение.

Новое дерево выглядит так:

(def routes
  ["/" {["content/order/" :id]
        {"/view" {:get  :page-view}
         "/edit" {:get  :page-form
                  :post :page-save}}}])

В этой версии листья уже не теги, а словари. Ключ такого словаря — метод HTTP-запроса, а значение — тег. Запрос “GET /content/order/1/edit” разрешается в тег :page-form, а POST с таким же адресом — в :page-save. При прохождении через wrap-handler запрос получит поле route-params. Для нашего случая это будет словарь {:id "1"}.

Вот так бы мог выглядеть обработчик page-edit. Получаем словарь заказа по его id. Если заказ найден, рисуем HTML страницу с формой редактирования. Если нет, отдаем 404 и сообщение об ошибке.

(defmethod multi-handler :page-edit
  [request]
  (let [order-id (get-in request [:route-params :id])
        order (get-order-by-id order-id)]
    (if order
      {:status 200
       :headers {:content-type "text/html"}
       :body (render-order-form order)}
      {:status 404
       :headers {:content-type "text/html"}
       :body "<h1>Order not found</h1>"})))

Выбор между Compojure и Bidi

Автору приходилось работать с роутингом обоих типов. По субъективным ощущениям, с Compojure легче начать. У библиотеки достойная документация с примерами. Compojure написал тот же разработчик, что и Ring. Проекты близки и дополняют друг друга.

Дерево маршрутов Bidi сложно для понимания. Оно многословно и не интуитивно. Легко допустить ошибку, перепутать вектор и словарь. С другой стороны, логика на мультиметодах несет преимущества. Код становится линейным, более организованным, приложение легче наращивать.

Если вы начинающий Clojure-разработчик или проект небольшой, выбирайте Compojure. Когда проект сложный со множеством эндпоинтов, рассмотрите переезд на Bidi.

Middleware

Выше мы упоминали про middleware и даже кинули пробный шар — написали wrap-route. В этом разделе мы разберем все вопросы о middleware и лучших практиках по работе с ними. Автор считает этому тему самой важной в главе.

В переводе с английского Middleware значит промежуточный слой, середина. В программировании под middleware понимают код, который обрабатывает данные между посредниками. Обработка данных это приведение типов, добавление новых полей, проверка прав доступа.

Паттерн “декоратор” это частный случай middleware. Декоратор это функция А, которая принимает функцию B и возвращает функцию C. Говорят, что A декорирует B. Результат декорирования это C. В ходе исполнения функция C вызывает B, но с изменениями. Например, корректирует входные или выходные данные B.

Приведем примеры простых декораторов. with-echo добавляет к функции побочные эффекты: печатает аргументы и результат.

(defn with-echo
  [func]
  (fn [& args]
    (apply println "The args are" args)
    (let [result (apply func args)]
      (println "The result is" result)
      result)))

With-catch оборачивает целевую функцию в форму try/catch. Если во время работы выброшено исключение, результатом будет его объект.

(defn with-catch
  [func]
  (fn [& args]
    (try
      (apply func args)
      (catch Throwable e
        e))))

Мы уже рассматривали структуру Ring-запроса. Возможно, читатель заметил, что в нем нет полей, с которыми он работал в других языках. Например, классы django.http.HttpRequest и flask.Request в Python содержат поля .params или .values. Это словари, полученные из адресной строки или тела запроса.

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

Представим, что на каждый запрос Ring парсит строку параметров и тело. Это удобно разработчику, но резко снижает производительность сервера. Нет гарантии, что параметры строки пригодятся в запросе. Но сервер потратит время и память на их обработку. Еще хуже с обработкой тела. Вспомним, что это дорогая операция. Возможен сценарий, когда сервер прочитал огромный JSON-документ, но внутри обработчика выяснилось, что у пользователя нет прав на запись. Эту проверку следовало выполнить раньше!

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

Вам не придется писать все middleware с нуля. Ring уже содержит основные из них. Остается только применить их к приложению. Рассмотрим некоторые middleware и принципы их работы.

Параметры запроса

Стандарт HTTP разрешает передавать данные в адресной строке. Это пары вида “name=John&city=NY” после знака вопроса. Удобно, когда параметры доступны в виде словаря. В нашем случае это была бы структура {:name "John" :city "NY"}.

Аналогично с параметрами из тела запроса. Их передают в теле по разным причинам. В основном это ограничение на длину и проблемы безопасности. Длина адресной строки ограничена 2048 байтами, в то время как на тело запроса ограничений нет. Пароли и адреса почты небезопасно передавать в адресной строке, потому что они остаются в логах и истории браузера.

Функция wrap-params из модуля ring.middleware.params меняет функцию-обработчик следующим образом. Переданный в нее запрос дополняется тремя полями:

  • :query-params — словарь параметров адресной строки;
  • :form-params — словарь данных из тела запроса;
  • :params — их комбинированная версия.

Пусть app — ваше веб-приложение. Чтобы получить его обернутую версию, достаточно вызвать wrap-params c app. Результат будет финальным приложением. На жаргоне разработчиков это называется “врапнуть” (анг. wrap — обернуть).

(require '[ring.middleware.params
           :refer [wrap-params]])

(def final-app
  (wrap-params app))

Чтобы не запутаться в именах, придерживайтесь правил. Не обернутое приложение называйте app-naked или app-raw (голое, сырое), а финальное просто app.

Доработайте веб-приложение из примера выше так, чтобы оно учитывало параметры строки. Например, чтобы имя того, кого приветствовать, можно было задать параметром who: /hello?who=John.

Подсказка: добраться до параметра who можно так:

(defn page-hello
  [request]
  (let [who (get-in request [:params "who"])]
    ...))

или так:

(defn page-hello
  [request]
  (let [who (-> request :params (get "who"))]
    ...))

Обратите внимание, что ключи :params это строки. Это нормально, но Clojure всячески поощряет нас, когда ключи словаря кейворды. Исправим это. В поставке Ring есть особое middleware, которое приводит поле :params к удобному виду. Это wrap-keyword-params из модуля ring.middleware.keyword-params:

(require '[ring.middleware.keyword-params
           :refer [wrap-keyword-params]])

(def app
  (wrap-keyword-params (wrap-params app-naked)))

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

(def app
  (wrap-something-else
    (wrap-current-user
      (wrap-session
        (wrap-keyword-params
          (wrap-params app-naked))))))

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

(def app
  (-> app-naked
      wrap-params
      wrap-keyword-params
      wrap-session
      wrap-current-user
      wrap-something-else))

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

Запись в стрелочном виде имеет особенность. Не заглядывая в следующее предложение, догадайтесь, в каком порядке будут выполнены middleware? Правильный ответ: снизу вверх для запроса и сверху вниз для ответа. Это может показаться странным, но становится очевидным при мысленном разборе.

Сперва запрос зайдет в wrap-something-else. Код внутри него вызовет обработчик, который получен из wrap-current-user. Обработчик внутри него – результат wrap-session, и так далее. Вершиной подъема станет app-naked. Структура ответа начнет опускаться по стеку вниз. Сначала он пройдет через wrap-params и wrap-keyword-params. Эти два middleware не изменяют ответ и просто возвращают его. Wrap-session и wrap-current-user, возможно, допишут в него новые заголовки. Последним сработает wrap-something-else. Цикл запроса и ответа пройден.

Цепочку middleware следует рассматривать как восхождение в гору и спуск с нее. Другой аналогией может быть пузырек, который всплывает и опускается (не имеет отношения к сортировке пузырьком).

По тому же принципу устроены middleware в Django, промышленном Python-фреймворке. Хоть в Django их роль играют не функции, а классы, их порядок обхода такой же.

Порядок middleware порой критичен. Некоторые из них опираются на данные, которые подготовили предыдущие middleware. Рассмотрим уже знакомые wrap-params и wrap-keyword-params. Последний отыскивает в запросе поле params и меняет тип ключей. Подразумевается, что params был подготовлен wrap-keyword-params. Поэтому wrap-keyword-params ставят строго после wrap-params.

Посмотрим на форму (def app...) выше. В нее закралась ошибка. Запрос поднимается снизу вверх, поэтому wrap-keyword-params сработает раньше. Он попытается найти поле params в запросе, но безуспешно. Следом сработает wrap-params. Он заполнит это поле словарем из адресной строки. В результате params будет словарем с ключами-строками. В следует поменять wrap-params и wrap-keyword-params местами.

Неверный порядок middleware стоит часов отладки. Но есть трюк. Если два и более middleware идут в строгой последовательности, можно “схлопнуть” их в одно целое. Стандартная функция comp принимает произвольное число функций и возвращает супер-функцию, которая последовательно применяет их к аргументу. Определим умный враппер параметров:

(def wrap-params+
  (comp wrap-keyword-params wrap-params))

Плюс на конце означает, что это улучшенная версия обычного wrap-params. Теперь заменим в стеке wrap-params и wrap-keyword-params на wrap-params+. Цепочка middleware станет короче, а логика параметров соберется в отдельном месте.

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

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

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

Set-Cookie: visited=true;

В последующих запросах браузер отправит это значение на сервер самостоятельно. Приложение проверяет: если visited true, значит пользователь уже был на сайте. Такие проверки влияют на показ рекламы, всплывающие окна, попапы с обновлениями.

Технически куки — это один длинный заголовок, где значения и атрибуты разделены специальными точками с запятой. Middleware wrap-cookie значительно облегчает работу с куки. Во время запроса заголовок преображается в словарь в поле :cookies. Чтобы сообщить клиенту новые куки, добавьте поле :cookies в ответ. Из такого словаря образуется заголовок Set-Cookie.

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

(require '[ring.middleware.cookies
           :refer [wrap-cookies]])

(defn page-seen
  [request]
  (let [seen-path [:seen :value]
        {:keys [cookies]} request
        seen? (get-in cookies seen-path)
        cookies (assoc-in cookies seen-path true)]
    {:status 200
     :cookies cookies
     :body (if seen?
             "Already seen"
             "The first time you see it") }))

(defn app
  (-> page-seen
      wrap-cookies))

Запустите приложение в браузере. После обновления страницы надпись изменится на “Already seen”. Обратите внимание, что даже после перезагрузки сервера ответ по-прежнему будет “Already seen”, потому что флаг хранится в браузере. Только очистив куки вы снова увидите “The first time you see it”. Для полноты эксперимента откройте приватную вкладку или другой браузер.

Куки тесно связаны с безопасностью. Даже если вам понятны технические детали, убедитесь, что куки защищены от кражи и не раскрывают секретные данные (пароли, ключи доступа). В этом разделе мы не обсуждаем тему веб-безопасности. Она слишком обширна для этой главы и заслуживает отдельной книги.

Сессии

Стандарт HTTP не предполагает связи между двумя запросами. Считается, что два запроса с соседних компьютеров разницей в пять минут связаны так же, как с разных континентов разницей в год. Но сразу с рождения веба разработчики нарушили стандарт. Понадобилось хранить состояние конкретного пользователя. Даже если компьютеры за одним столом, сервер должен различать их. Это назвали сессией или сеансом.

Wrap-session это довольно сложное middleware. Оно дополняет запрос полем :session, в котором словарь. Его ключи — поля сессии. Чтобы обновить сессию, следует положить ее новую версию в ответ по аналогии с :cookie. Middleware различает nil и факт отсутствия сессии в ответе. Если поле :session nil, вся сессия удаляется. Если ключа нет, ничего не происходит.

Сессия это абстрактное понятие, поэтому различают бэкенды сессии. Это разные способы хранить значения физически. Сессия может храниться в памяти, на диске, в базе данных, Memcached/Redis или даже куках. При выборе бэкенда важно учитывать, способен ли он работать на нескольких машинах одновременно. Что получится, если каждый запрос на случайно выбранной из десяти машин?

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

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

В стандартной поставке Ring сессия хранится в памяти или куках. Хранилище определяется настройками wrap-session. Ring закладывает необходимые абстракции, чтобы хранить сессию в базе или key-value системах типа Redis.

Рассмотрим пример со счетчиком посещений. Будем считать, сколько раз пользователь зашел на наш сайт. Для простоты храним сессию в памяти.

(require '[ring.middleware.session
           :refer [wrap-session]])

(defn page-counter
  [request]
  (let [{:keys [session]} request
        session (update session :counter (fnil inc 0))]
    {:status 200
     :session session
     :body (format "Seen %s time(s)" (:counter session))}))

(defn app
  (-> page-counter
      wrap-session))

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

Упражнение: в примере выше мы считаем просмотры для всего сайта. Сделайте так, чтобы счетчик работал в рамках страниц. Например, главная страница / просмотрена пять раз, а справка /help — три раза. Параметры командной строки не влияют на подсчет.

JSON

Формат JSON предназначен для передачи данных. Среду прочих его достоинств — типы, вложеность и совместимость с JavaScript.

JSON различает базовые типы данных — числа, строки, логический тип. Это выгодно отличает его от параметров адресной строки или XML, где все значения строки.

Формат предусматривает основные коллекции — массив и словарь — и их произвольную вложенность. В разное время были попытки передать вложенные данные в адресной строке. Общий подход был в том, чтобы ключ содержал путь внутри структуры. Например, если в данных несколько адресов, а у каждого адреса несколько строк (line 1, line 2, etc), то получается что-то вроде:

address[0].line[0].value=SomeStreet

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

JSON совместим с JavaScript. Если передать такой документ в функцию eval, она вернет данные — комбинацию списков и словарей.

Все это способствовало тому, чтобы JSON стал главным способом передать данные в интернете.

Ring предлагает набор middleware для JSON. Они вынесены в отдельный пакет для удобства разработки. Добавим в проект зависимость:

[ring/ring-json "0.4.0"]

wrap-json-response облегчает возврат JSON-данных. Это middleware проверяет поле ответа :body. Если это коллекция (вектор, словарь), то middleware заменяет его на кодированную строку и выставляет заголовок Content-Type: application/json.

Рассмотрим пример:

(require '[ring.middleware.json
           :refer [wrap-json-response]])


(defn page-data
  [request]
  {:status 200
   :body {:some {:json ["data"]}}})

(def app
  (-> page-data
      wrap-json-response))

Более сложный пример. Если пользователь нашелся, возвращаем его модель. Если нет, то структуру ошибки.

(defn page-data
  [request]
  (let [user-id (-> request :params :id)]
    (if-let [user (get-user-by-id user-id)]
      {:status 200
       :body user}
      {:status 404
       :body {:error_code "MISSING_USER"
              :error_message "No such a user"
              :error_data {:id user-id}}})))

Для входящего JSON-документа в библиотеке два middleware. Это wrap-json-body и wrap-json-params. На фазе запроса оба проверяют, что заголовок Content-Type содержит application/json. Если да, они парсят тело с учетом возможных исключений. При ошибке разбора ответ будет 400 “JSON body malformed”.

Разница между wrap-json-body и wrap-json-params в том, куда они складывают полученные данные.

Wrap-json-body заменяет поле :body запроса на полученную структуру данных. В примере ниже обработчик page-body извлекает имя и город пользователя из :body. Тело запроса уже не входящий поток, а структура данных, о чем заботится wrap-json-body. Обратите внимание, middleware принимает опциональные параметры. Флаг :keywords? true Означает, что ключи словарей должны быть приведены к кейвордам.

(require '[ring.middleware.json
           :refer [wrap-json-body]])

(defn page-body
  [request]
  (let [{:keys [body]} request
        {:keys [username city]} body]
    (create-user username city)
    {:status 200
     :body {:code "CREATED"
            :message "User created"}}))

(def app
  (-> page-body
      (wrap-json-body {:keywords? true})))

Чтобы отправить JSON-запрос к серверу, понадобится специальная программа. Это может быть утилита cURL или графическое приложение Postman. Пример с cURL:

curl \
  --header "Content-Type: application/json" \
  --request POST \
  --data '{"username":"John","city":"NY"}' \
  http://localhost:8080/

Вариант с wrap-json-params Отличается тем, где хранится структура данных. Это middleware заносит данные в поле :json-params. В дополнение, если данные были словарем, они вливаются в поле :params. Это поле, как мы помним, используются другими врапперами, например, wrap-params.

Таким образом, :params выступает универсальным аккумулятором параметров. Продвинутое API может быть устроено так, что клиент вправе передавать данные удобным ему способом. Например, GET-запросом с параметрами строки, если это данные для чтения. POST с переменными в теле, чтобы изменять сущности. Или POST с JSON-телом, если данные с глубокой вложенностью.

Вспомним, что params это словарь с ключам-строками. По этой причине wrap-json-params сохраняет строки в ключах, чтобы слияние прошло правильно. Чтобы исправить ключи :params на кейворды, используйте уже знакомое нам wrap-keyword-params. Оно должно быть ниже wrap-json-params по стеку.

Разработчики не случайно выделяют поле :json-params. Тело JSON-документа не обязательно словарь, это может быть массив. Такую структуру невозможно влить в :params. Документ помещают в :json-params, и если это словарь, объединяют с :params.

Продемонстрируем сказанное на примере. Передаем данные гибридно: username в теле JSON-документа и city в параметрах строки. Обратите внимание на стек middleware. Сперва мы парсим параметры строки, затем тело документа. Оба словаря накапливаются в :params. Затем, уже после их накопления, исправляем тип ключей.

(require '[ring.middleware.json
           :refer [wrap-json-params]])

(defn page-params
  [request]
  (let [{:keys [params]} request
        {:keys [username city]} params]
    (create-user username city)
    {:status 200
     :body {:code "CREATED"
            :message "User created"}}))

(def app
  (-> page-params
      wrap-keyword-params
      wrap-json-params
      wrap-params))

Пример обращения к серверу:

curl \
  --header "Content-Type: application/json" \
  --request POST \
  --data '{"username":"John"}' \
  http://localhost:8080/?city=NY

Собственные middleware

До сих пор мы использовали сторонние врапперы. Это те, что идут в поставке Ring и смежных библиотек. Но рано или поздно вам потребуются собственные. Обычно их накапливают в модуле с именем <projectname>.middleware. Рассмотрим примеры из реальных проектов.

wrap-headers-kw

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

(require
 '[clojure.walk :refer [keywordize-keys]])

(defn wrap-headers-kw
  [handler]
  (fn [request]
    (-> request
      (update :headers keywordize-keys)
      handler)))

wrap-request-id

В протоколе HTTP запрос и ответ не связаны друг с другом. Порой трудно понять, к какому запросу относится тот или иной ответ и наоборот. Важно, чтобы система могла их сопоставить. Например, была серия ответов с кодом 500, но какие именно запросы вызвали ошибку?

Для этого ввели заголовок X-Request-Id. Если клиент не передал идентификатор запроса, мы назначаем ему случайный. Тот же идентификатор возвращаем в ответе. Все записи в лог содержат этот идентификатор.

Обратите внимание, что мы обращаемся к заголовкам как к ключевым словам. Мы ожидаем, что wrap-headers-kw был выше по стеку.

(import 'java.util.UUID)

(defn wrap-request-id
  [handler]
  (fn [request]
    (let [uuid (or (get-in request [:headers :x-request-id])
                   (str (UUID/randomUUID)))]
      (-> request
          (assoc-in [:headers :x-request-id] uuid)
          (assoc :request-id uuid)
          handler
          (assoc-in [:headers :x-request-id] uuid)))))

Мы храним идентификатор не только в заголовках, но и на уровне запроса в поле :request-id. Для записи в лог мы будем часто обращаться к нему. Поэтому вынесем в отдельную переменную вместе с другими полями в начале функции:

(defn some-handler
  [request]
  (let [{:keys [params request-id]} request]
    (log/info "Request id: %s" request-id)))

wrap-current-user

Этот враппер определяет текущего пользователя системы. Стратегия в том, что в запросе содержится идентификатор пользователя. В данном случае мы ищем его в сессии. Если идентификатор найден, читаем модель пользователя и присоединяем к запросу. Ожидается, что функция get-user-by-id знает, как извлекать данные о пользователе. Чаще всего это запрос к базе данных.

(defn wrap-current-user
  [handler]
  (fn [request]
    (let [user-id (-> request :session :user-id)
          user (when user-id
                 (get-user-by-id user-id))]
      (-> request
          (assoc :user user)
          handler))))

Условно говоря, хранить user-id в сессии безопасно. Сессия подписана секретным ключом, поэтому только сервер может менять ее значения. Не допускайте, чтобы user-id передавался в параметрах командной строки.

Прерывание стека

Выше мы рассмотрели непрерывную цепочку middleware, где каждое звено передает управление следующему. Но логика middleware не всегда линейна. Бывает, цепочку необходимо разорвать. Например, еще на уровне middleware мы определили, что пользователь не имеет прав к данному ресурсу. Продолжать цепочку по обычному пути не имеет смысла. Наоборот, мы должны как можно скорее вывалиться из стека.

Стандартные врапперы из примеров выше работают на условиях. Так, wrap-json-params читает тело только в том случае, если заголовок Content-Type установлен в application/json. Если в нем что-то другое, он оставит поток нетронутым. При разборе JSON-документа ловится возможное исключение. Такое возможно, если документ сформирован с ошибками или поврежден при передачи. В таком случае wrap-json-params не продолжает цепочку. Он возвращает ответ с текстом “JSON body malformed”. Ни одно middleware ниже по стеку не сработает.

Рассмотрим частный случай с проверкой доступа. Предположим, приложение доступно только авторизованным пользователям. Мы уже определили текущего пользователя в wrap-current-user. То middleware только определяет пользователя, но не ограничивает доступ. Добавим ниже по стеку другое:

(defn wrap-auth-user-only
  [handler]
  (fn [request]
    (if (:user request)
      (handler request)
      {:status 403
       :headers {:content-type "text/plain"}
       :body "Please sign in to see that page."})))

Теперь все middleware ниже wrap-auth-user-only не сработает если пользователь не авторизован.

Вспомним, цепочку middleware можно представить как восхождение и спуск с горы. Если один из элементов терпит неудачу, мы как будто срезаем верхушку. Словно добрались до середины, столкнулись с проблемой и повернули обратно. Общее правило: чем раньше мы обнаружим проблему, тем меньше потратим сил. Поэтому более общие проверки мы ставим выше по стеку.

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

Что случится, если при обработке запроса выброшено исключение? Не существует четких правил на этот счет. Каждый сервер или фреймворк обрабатывает исключения.

Один сервер покажет в браузере стек-трейс. Другой сервер вернет HTML-страницу с отладочной информацией. Разработчики третьего посчитали, что выводить стек-трейс небезопасно. Исключение пишут в лог, а в ответе статус 500 и фраза “Internal Server Error”.

Хорошо, когда разработчик сам определяет, что делать с исключениями. Ниже простое middleware, которое перехватывает потенциальную ошибку, пишет ее в лог и возвращает ответ-заглушку:

(defn wrap-exception
  [handler]
  (fn [request]
    (try
      (handler request)
      (catch Throwable e
        (let [{:keys [uri
                      request-method]} request]
          (log/errorf e "Error, method %s, path %s"
                      request-method uri)
          {:status 500
           :headers {:content-type "text/plain"}
           :body "Sorry, please try later."})))))

В примере выше log/errorf это макрос для записи ошибок. Он принимает объект исключения, шаблон и параметры. Мы хотим знать, какие были метод и путь запроса, поэтому записываем их тоже. Это значительно облегчит анализ логов в будущем.

Чем выше wrap-exception расположено в стеке, тем меньше шансов возникнуть не пойманному исключению. В идеале оно стоит на вершине цепочки, чтобы гарантированно ловить все исключения.

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

Чтобы разделять бизнес- и технические проблемы, на границах стека middleware расставляют разные wrap-exception. Самое нижнее оборачивает непосредственно app-naked. Оно отлавливает исключения в бизнес-логике. Такую ошибку обрабатывают подробно, во всех деталях. На вершине стека другая, облегченная версия wrap-exception. Его задача — ловить мелкие ошибки, связанные с предварительной обработкой запроса. По большей части это для того, чтобы возвращать адекватный ответ пользователю.

Middleware вне стека

Интересен сценарий, когда middleware должно оказать эффект только на запросы по определенному пути. Вернемся к wrap-auth-user-only. В чем его недостаток? Если включить его в стек, анонимный пользователь не увидит ни одну страницу. Абсолютно любой запрос будет отклонен со статусом 403. Главная страница, контактные данные, форма входа — все страницы недоступны. В этом нет никакого смысла.

Очевидно, wrap-auth-user-only должен перекрывать только некоторое подмножество запросов. Например, тех, что начинаются с /account: /account/cart, /account/orders и т.д. Место wrap-auth-user-only не в общем стеке, а ниже — на уровне роутинга.

Дальнейшая реализация зависит от того, как мы строим маршруты. В Compojure есть особое middleware под названием wrap-routes. Оно принимает правило и другое middleware. Если правило накладывается на текущий запрос, то целевой обработчик оборачивается в переданное middleware. Столь сложная логика нужна, чтобы не вызывать middleware, пока запрос не совпадет с правилом.

Вынесем семейство маршрутов для аккаунта в отдельную ветку:

(defroutes account-routes
  (with-context "/account" []
    (GET "/profile" request (account-profile request))
    (GET "/orders" request (account-orders request))
    (GET "/cart" request (account-cart request))))

Обернем аккаунты в маршрутах верхнего уровня:

(defroutes app
  (GET "/" request (page-index request))
  (GET "/help" request (page-help request))
  (wrap-routes account-routes wrap-auth-user-only))

Теперь wrap-auth-user-only сработает только для обработчиков account-profile, account-orders и account-cart.

Все вместе

Middleware, которое принимает middleware — довольно крутая абстракция. Если вы действительно поняли, как это работает и почему именно так — примите поздравления. Это серьезный рубеж.

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

Цепочку middleware называют стеком. Во время запроса мы движемся по стеку снизу вверх, во время ответа — сверху вниз. Такой обход можно сравнить с восхождением в гору. Каждое middleware может прервать цепочку в зависимости от обстоятельств. Легче всего выразить стек с помощью стрелочного оператора. Это экономит скобки и делает структуру наглядней.