Главная мысль урока: веб в Clojure стоит на одном контракте. Запрос это мапа, ответ это мапа, обработчик это функция от первой ко второй. Middleware это функция от обработчика к обработчику. Сервер нужен только снаружи, а всю логику проверяет обычный вызов функции.
Клиент «Хроник Пограничья» спрашивает у сервера здоровье Ирмы. По проводу идёт текст: GET /heroes?name=Ирма HTTP/1.1, заголовки, пустая строка. Сервер на Clojure разбирает этот текст и отдаёт твоему коду не объект запроса с методами, а мапу:
hello это весь «контроллер». Он не знает про сокеты и потоки, принимает мапу и возвращает мапу. Запустить его можно в REPL, в тесте, в браузере, как сейчас. Такой договор называется Ring, и на нём стоит почти весь веб в Clojure: серверы, маршрутизаторы, библиотеки авторизации.
Запрос и ответ это данные
Главные ключи запроса:
| Ключ | Пример | Что это |
|---|---|---|
:request-method | :get, :post | метод HTTP ключевым словом |
:uri | "/heroes" | путь без строки запроса |
:query-string | "name=Ирма" | всё после ?, сырой строкой |
:headers | {"accept" "application/edn"} | заголовки, имена в нижнем регистре |
:body | поток байтов | тело запроса, если есть |
У ответа ключей три: :status, :headers и :body. Тело чаще всего строка. Больше ничего знать не нужно, чтобы написать обработчик.
Представь окошко в ратуше Пограничья. Писарь (сервер) принимает от путника устную просьбу, записывает её на бланк с графами «что нужно», «куда», «от кого» и передаёт бланк чиновнику (обработчику). Чиновник не видит путника и не слышит его акцента: он читает бланк и заполняет бланк ответа. Поэтому одного чиновника можно посадить за любое окошко, а проверить его работу, подсунув ему бланк без всякого путника. Ring и есть этот бланк: одна и та же функция работает под http-kit, под Jetty или в тесте. Сравни с уроком про HTTP: там те же метод, путь, заголовки и тело, только здесь они уже разобраны в мапу.
Маршруты данными
Сервису нужно больше одного адреса. Маршрутизатор выбирает обработчик по методу и пути. Его можно написать через cond, но в духе Clojure удобнее завести таблицу маршрутов мапой:
Маршруты это мапа, ключ это пара «метод, путь». Её можно напечатать, отфильтровать, проверить в тесте, что у каждого адреса есть обработчик. Состояние мира живёт в одном атоме, как в уроке про него, а обработчик меняет его через swap!.
Библиотеки маршрутизации (reitit, compojure) умеют больше: параметры в пути вроде /heroes/:name, вложенные группы, схемы. Идея у той же reitit прежняя: маршрут это вектор данных.
Middleware: функция от обработчика
Посмотри на обработчики: они возвращают тело то списком, то строкой, а клиенту нужен текст в одном формате. Можно переделать каждый обработчик. А можно обернуть их все одной функцией, которая принимает обработчик и возвращает новый. Это middleware:
Ответ стал текстом EDN из урока про EDN, и ни один обработчик об этом не знает. Middleware можно складывать слоями. Вот ещё два: первый превращает ошибку ex-info из урока про интероп в ответ с кодом из её данных, второй разбирает строку запроса в мапу :params:
Прочитай (-> hero-hp wrap-params wrap-errors) изнутри наружу. Запрос сначала попадает в wrap-errors, тот зовёт wrap-params, тот добавляет :params и зовёт hero-hp. Ответ идёт обратно тем же путём:
запрос ──▶ wrap-errors ──▶ wrap-params ──▶ hero-hp
ответ ◀── wrap-errors ◀── wrap-params ◀──┘
ловит ex-info добавил :params
Это те же слои, что в Express или в middleware Astro, только без особого API: обычные функции, которые склеивает ->. В настоящем проекте wrap-params берут готовый из ring.middleware.params, а JSON разбирает ring-json или muuntaja.
Тесты без сервера
Раз приложение это функция, тест это вызов функции. Ни порта, ни HTTP-клиента:
Тесты проверяют ровно то, что увидит клиент: статус и тело. Сервер добавит к этому только сеть.
Сервер одной строкой
Сеть поднимает адаптер: http-kit или Jetty. Он слушает порт, превращает текст HTTP в мапу запроса, вызывает твою функцию и пишет мапу ответа обратно в сокет. В браузере сервер не запустить, поэтому этот листинг проверяется в babashka, где http-kit встроен. Он поднимает сервер на свободном порту, делает два запроса и останавливает сервер:
(require '[org.httpkit.server :as http]
'[babashka.http-client :as client]
'[cheshire.core :as json])
(def world (atom {"Ирма" {:hero/hp 18 :hero/max-hp 30}}))
(defn query-param [req k]
(some->> (:query-string req)
(re-find (re-pattern (str k "=([^&]+)")))
second
(java.net.URLDecoder/decode)))
(defn hero-hp [req]
(let [name (query-param req "name")]
(if-let [hero (get @world name)]
{:status 200
:headers {"Content-Type" "application/json"}
:body (json/generate-string hero)}
{:status 404 :body "нет героя"})))
(defn router [req]
(case [(:request-method req) (:uri req)]
[:get "/hp"] (hero-hp req)
{:status 404 :body "нет такого адреса"}))
;; Порт 0: система выдаст свободный.
(def server (http/run-server router {:port 0 :legacy-return-value? false}))
(def base (str "http://localhost:" (http/server-port server)))
(try
(let [ok (client/get (str base "/hp") {:query-params {"name" "Ирма"}})
missing (client/get (str base "/nope") {:throw false})]
(assert (= 200 (:status ok)))
(assert (= {:hero/hp 18 :hero/max-hp 30} (json/parse-string (:body ok) true)))
(assert (= 404 (:status missing)))
(println "GET /hp →" (:body ok)))
(finally
@(http/server-stop! server)))
run-server принимает тот же обработчик, что тесты вызывали напрямую. По проводу имя приходит закодированным, name=%D0%98%D1%80..., поэтому здесь его раскодирует URLDecoder из Java; готовый wrap-params делает это сам. В ответе JSON, потому что клиентом может быть браузер или сервис на Go. Ключи :hero/hp при этом стали строками "hero/hp", а parse-string с true вернул их ключевыми словами: та граница, о которой шла речь в уроке про EDN.
В проекте сервер запускают из -main и не останавливают:
(defn -main [& _]
(http/run-server (-> router wrap-params wrap-errors) {:port 8080})
(println "Хроники слушают :8080"))
Проверь себя
Задание
Дальше
Middleware склеивает функции в конвейер, через который идёт запрос. В уроке про трансдьюсеры похожий приём применим к последовательностям: склеим map и filter в одно преобразование, которое не строит промежуточных коллекций.
домашка