Раздел 28 · Clojure

HTTP-сервис на Ring

middle~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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

домашка

Домашка