Cognitect Support for Clojure Events

clojure.png

Cognitect (formerly Relevance) has run many Clojure events since 2010. The first Conj was put together for the rapidly growing Clojure community in the early days of Clojure and it became an annual event run primarily on the east coast of the US. In 2012 and 2013, Alex Miller started and ran Clojure/west as an independent event, later becoming a Cognitect event after Alex joined Cognitect. EuroClojure was created by the European Clojure community and run by Marco Abis, then transitioned into a Cognitect event in 2015.

Throughout, the goal has to been to provide places for Clojurists to gather and talk about their favorite language, provide opportunities to learn and share tools, techniques, and successful experiences. In the last few years, we've seen the rise of many great new Clojure community events in London, Berlin, Amsterdam, Helsinki, Bangalore, Leuven, Toronto, New Orleans, and some online as well.

Moving forward, Cognitect will continue to produce the Clojure/conj event on an annual basis every fall. We will discontinue the EuroClojure and Clojure/west events and increase our sponsorship of community conferences, provide speakers as desired, and offer other support as needed for worldwide Clojure community events. If you are a Clojure conference organizer, please contact events@cognitect.com with any questions or requests.

We look forward to seeing you at Clojure events wherever they happen!

Permalink

Введение в веб-разработку на Clojure. Часть I

(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, Symphony. Названия классов и их композиция различаются, но суть остается прежней. Это приложение, маршрутизатор, обработчик, запрос и ответ. Проблема в том, что каждый фремворк моделирует собственные классы, которые несовместимы между собой в рамках языка.

Рассмотрим язык 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-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 значительно облегчает работу с куки. Во время запроса заголовок преображается в словарь в поле :cookes. Чтобы сообщить клиенту новые куки, добавьте поле :cookes в ответ. Из такого словаря образуется заголовок 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, в котором словарь. Его ключи — поля сессии. Чтобы обновить сессию, следует положить ее новую версию в ответ по аналогии с :cookei. Middleware различает nil и факт отсутствия сессии в ответе. Если поле :session, вся сессия удаляется. Если ключа нет, ничего не происходит.

Сессия это абстрактное понятие, поэтому различают бэкенды сессии. Это разные способы хранить значения физически. Сессия может храниться в памяти, на диске, в базе данных, 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 может прервать цепочку в зависимости от обстоятельств. Легче всего выразить стек с помощью стрелочного оператора. Это экономит скобки и делает структуру наглядней.

Permalink

Package a Clojure web application using Docker

This is the second blog post in a three-part series about building, testing, and deploying a Clojure web application. You can find the first post here.

In this post, we will be focusing on how to add a production database (PostgreSQL, in this instance) to an application, how to package the application as a Docker instance, and how to run the application and the database inside Docker. To follow along, I would recommend going through the first post and following the steps to create the app. Otherwise, you can get the source by forking this repository and checking out the master branch. If you choose this method, you will also need to set up your CircleCI account as described in the first post.

Permalink

Quality Assurance Engineer

Quality Assurance Engineer

MX Healthcare | Berlin
MX Healthcare improves Healthcare by applying Deep Learning to Radiology
€50000 - €70000

What we do


Merantix transforms how businesses, healthcare organizations, and governments operate by building products that apply machine intelligence to their enterprise datasets. We acquire datasets that enable promising products, incubate those products, and spin off ventures to support their growth. We take advantage of three core competencies: Our experience applying machine learning, our expertise fostering enterprise partnerships to assemble vast datasets, and our culture of rapidly iterating to build scalable products.

Our projects are meaningful and diverse. Currently we work on:

  • Automated medical image diagnosis
  • Financial models for automated trading on new markets
  • Deep neural networks used for autonomous driving

As our current ventures progressively grow mature and become more independent, we will look into new industries such as satellite imagery, pharmaceutical drugs, industrial IoT and automated manufacturing.

Our team is made up of entrepreneurs, scientists, and engineers from premier universities around the world. Many of us have PhDs and work experience at top tech companies. We're based in Europe's startup capital, Berlin, and are growing quickly!

Job

You want to work on something with a purpose and make healthcare more safe and efficient?

Merantix is looking for a Quality Assurance Engineer. You will be working on our product team, testing a platform to securely view and report on X-Rays, MRIs, and CT-scans. Our product is written in Clojure and Python, and this unique role will involve automated testing (mostly in Clojure; you can learn it on the job!), as well as exploratory/manual testing where appropriate.

What we provide

  • Work in healthcare: You get to work in an innovative, well-funded startup becoming part of the healthcare industry.
  • Startup: Don't lose time due to politics, build things and have an impact instead
  • Stay ahead of industry trends: You'll have the chance to build on your existing QA skills while learning a functional language.
  • Great team, opportunity for growth, mentoring: Our engineers are active members of the Berlin Clojure community

Qualifications

We define ourselves by a culture of friendship and ownership. We're looking for capable, driven, and thoughtful people who think outside the box and add to our vision.

Basic qualifications:

  • You're self motivated and want to take ownership of product quality
  • You have a solid understanding of quality assurance practices and test strategies
  • You're interested in Clojure and functional programming and willing to learn it (if you already have experience in this area, that's great too!)

Preferred qualifications:

  • Understanding of web application fundamentals (JavaScript/HTML/CSS, REST APIs, relational databases, single page applications, etc.)
  • Experience building automated test suites
  • Experience with sound engineering practices: Git, code review, testing, continuous delivery and automation

The preferred qualifications are just that: preferred. If a few of these points apply to you, we definitely want to talk!

Working at Merantix:

Our hierarchy is flat and communication direct, which means that we operate and learn fast, as a team. You are a great fit for our team if this describes you:

  • Entrepreneurial mindset: Creative, focused, and not easily discouraged
  • An ability to work in a fast-paced environment where continuous innovation is occurring and ambiguity is the norm.
  • Excellent technical and analytical problem-solving skills
  • Structured work style to develop effective solutions with minimal oversight
  • Analyze and solve problems at their root, stepping back to understand the broader context
  • Strong organizational and multitasking skills with ability to balance competing priorities
  • Excellent communication (verbal and written) and interpersonal skills and an ability to effectively communicate with both business and technical teams.
  • Love what you do (and own it)

In addition to a competitive salary and equity package, you can expect the following:

Work-life balance:

Some of us come in during regular business hours, and some of us prefer to start later or earlier. Occasionally, we work from home in the mornings or evenings rather than in the office. We don't track vacation time, and expect you to take time off as you need it. And while the infrequent unavoidable deadline may demand extraordinary effort, we value our evenings and weekends and overtime is the exception, not the rule. Just ask one of our founders, a father of two.

Professional Development:

We aim for attending at least one professional conference each year. Our entire machine learning team attended NIPS and ICCV while our product team attended ClojuTRE, clojure/conj and clojureD. We make major contributions to the open source community. We have weekly engineering meetings both for machine learning and Clojure development which includes presenting papers, talking through interesting pieces of code or just mentioning some cool libraries.

Collaborative Culture:

We have regular outings and team dinners. If you want to participate, that's great! And if you don't, that's fine too.

We are an equal-opportunity employer and value diversity. We consider all applications equally regardless of race, color, ancestry, religion, sex, national origin, sexual orientation, age, citizenship, marital status, disability, or gender identity. We strongly encourage individuals from groups traditionally underrepresented in tech to apply, and we can help with immigration.

Permalink

PurelyFunctional.tv Newsletter 318: Tip: Beware the order of keys in hashmaps

Issue 318 – March 18, 2019 · Archives · Subscribe

Clojure Tip 💡

Beware the order of keys in hashmaps

You cannot rely on key-value pairs in hashmaps coming out in the same order as they went in. The trouble is, when you test, it might appear that you can. It won’t be until later, with a larger input, that the problem arises. But at that point, your mind has moved onto new problems, and you won’t know where to look.

This tip is probably obvious to you, but just to make sure it’s really clear, I’ll demonstrate.

Maps may keep their order for small inputs

Here’s some code, with a small input, that shows that the keys come out in the same order as they went in.

(keys (into {} [[:a 1] [:b 2] [:c 3]])) 
;=> (:a :b :c) 

In fact, I can go all the way up to 8 key-value pairs in a map on my machine and it still maintains the order:

(keys (into {} [[:a 1] [:b 2] [:c 3] [:d 4] [:e 5] [:f 6] [:g 7] [:h 8]]))
;=> (:a :b :c :d :e :f :g :h)

But adding a ninth scrambles them:

(keys (into {} [[:a 1] [:b 2] [:c 3] [:d 4] [:e 5] [:f 6] [:g 7] [:h 8] [:i 
9]]))
;=> (:e :g :c :h :b :d :f :i :a)

Why do maps keep their order for small inputs?

Well, it’s an implementation detail. Small maps are of type PersistentArrayMap.

(type (into {} [[:a 1] [:b 2] [:c 3]])) 
;=> clojure.lang.PersistentArrayMap

Large maps are of type PersistentHashMap.

(type (into {} [[:a 1] [:b 2] [:c 3] [:d 4] [:e 5] [:f 6] [:g 7] [:h 8] [:i 
9]]))
;=> clojure.lang.PersistentHashMap

ArrayMaps happen to maintain order (though the interface does not guarantee it). And at some point, associng one more key-value pair will flip it over into a HashMap.

Why is this a problem?

Well, at small inputs, if you’re doing interactive tests, it looks like you are getting a correct result. It looks like your algorithm is correct, even if you are relying on order.

The trouble is that we often use small inputs when we’re testing. That testing will give you a false confidence. You’ll move on to other areas of the code. And when things start breaking, you won’t know where to look.

How to fix it

Well, it’s not so easy. I suggest two habits.

  1. Think of all maps as unordered, like bags of stuff. You put stuff in a bag, shake it up, and things get mixed up.
  2. Always test with an input at lest 10 elements long. I usually do this test at the end, just to catch ordering problems.

Brain skill 😎

Y’all, we all need to practice a little bit more deliberate practice. Deliberate practice is when you practice with a purpose, and it is the key to mastery. We all write a lot of code, either at work or for school assignments, or even for fun. But are we focused on improving a particular skill?

In deliberate practice, you break down a large skill (say, Clojure programming) into smaller skills (paren management, IDE keystrokes, data structure usage, concurrency, etc). You then focus practice on that skill until mastery. Then you move on to the next.

How small should the skills be? Research shows that you want a skill you can master (achieve 95% accuracy) within 1-3 45-minute sessions (1 is better than 3). If you can’t master it in that time, you need smaller skills.

At 95% accuracy, the skill is reliable and automatic. Under that, you can still do the skill, but only with effort. The trouble is, without pushing past the 95% limit, we’ll have lots of skills that take a lot of effort. It’s better to have a partial skill at 95% accuracy than a full skill at anything less. That’s why it’s important to break things down and focus on one at a time.

Also, you’ll need to have a way of measuring your progress. How do you know how accurate you are? That’s one place where a REPL really shines. It gives you really fast feedback to let you know you’re on track.

Reference: Badass: Making Users Awesome by Kathy Sierra.

Action

Write down a skill you would like to learn. Could you master it in 45 minutes? Chances are, it’s too big. Break it into smaller skills until you’re confident that 45 minutes of practice will get you there.

Clojure Puzzle 🤔

Last two weeks of puzzles

When I got back from vacation, I was so happy to see so many great submissions to both puzzles.

Issue 316 drop every nth element

It’s really amazing how concise these can be. See the submissions.

Issue 317 run-length encoding/decoding

There were some really great answers. See them here.

Thanks to all those who submitted.

This week’s puzzle

generate combinations

If I have 4 flowers to choose from (#{:rose :lily :daisy :tulip}), I can generate 4 different combinations of 3 flowers.

(#{:rose :lily :daisy}, #{:rose :lily :tulip}, #{:rose :daisy :tulip}, #{:lily 
:daisy :tulip})

Write a function combinations that takes a collection of values and a number of items to choose and generates all combinations of that size.

Example:

(defn combinations [coll n]
 ...)

(combinations #{:rose :lily :daisy :tulip} 3)
; => (#{:rose :lily :daisy}, #{:rose :lily :tulip}, #{:rose :daisy :tulip}, 
#{:lily :daisy :tulip})

Bonus points for clarity, interest, and efficiency.

As usual, please send me your implementations. I’ll share them all in next week’s issue. If you send me one, but you don’t want me to share it publicly, please let me know.

Rock on! Eric Normand

PS ✍️

I have started recording a new course called Repl-Driven Development in Clojure. It’s an important topic and there just isn’t enough of a comprehensive take on the subject. Repl-Driven Development is where Clojure shines, and without it, Clojure can seem like a drag.

The course is available as of right now as part of an early access program (read serious discount). If you buy now, you’ll receive updates to the course as new lessons come out. There is already 1.5 hours of video, and many more coming. If I had to guess, I’d say 6-8 hours total when I’m done. But I can’t be sure. That’s what the discount is for. As the course is fleshed out, the price will go up.

Of course, members of PurelyFunctional.tv will get the course as part of their membership for no extra cost. Just another benefit of being a member.

Check out the course. The first lesson is free.

The post PurelyFunctional.tv Newsletter 318: Tip: Beware the order of keys in hashmaps appeared first on PurelyFunctional.tv.

Permalink

Do locks slow down your code?

Yes. Locks slow down your code. But they enable your code to be correct! It’s a tradeoff, but who would ever trade correctness for a little speed? In this episode, we look at the tradeoff, how to make locks less of a speed tradeoff, and some alternatives.

Transcript

Eric Normand: Locks slow down your code. By the end of this episode, I hope to give an engineer’s perspective on the time trade-off of locks.

My name is Eric Normand. I help people thrive with functional programming. This is an important topic because locks are one way, an important way to achieve concurrency. That means sharing of resources between different threads. We need to understand their trade-offs.

This idea, discussion topic was something that was brought up by someone I was having a phone conversation with.

I was talking about how locks are an important concurrency mechanism and he brought up, “Yeah, but they slow down your code.” It struck me as funny to bring that up because I did learn that in school. I remember specifically in class when we learned about locks at university that that was a primary thing that was of concern like, “Remember, these are going to slow down your code.”

I don’t know why people talk about that, because after years of actually building systems using locks and other concurrency primitives, it is just so clear how much more important correctness is than that kind of speed.

It might be an old-fashioned idea because maybe they weren’t so fast. Computers weren’t as fast back then, and maybe locks were considered pretty expensive, and so that university classes often lagged behind the technology a little bit. Professors are just teaching what they learned in school, and this person is older than I am so he probably learned that even before. His professors were even older.

It’s one of those things that I think we have to get over. That there’s nothing you want to trade-off for correctness.

If your program doesn’t do what it’s supposed to do, then it doesn’t matter how fast it is. It just doesn’t make any sense. That’s why it struck me as funny. I just wanted to bring that up and re-emphasize it. Now, that’s not to say that they don’t slow down your code. They definitely do.

There’s a few things that we have to talk about with that. Locks definitely are slower than letting your threads run without locks. There’s no question about that. The problem is without locks or some other kind of concurrency primitive, you run the risk of what are called race conditions.

I have a whole episode on race conditions. Race conditions generally, in a nutshell, mean your threads are not sharing nicely. For example, they could be using the same variable to store stuff in, or the same mutable data structure. They’re reading and writing at the same time over each other.

It’s like you’re trying to all color on the same paper at the same time. You’re bumping into each other, and you’re like, “Hey, I wanted that to be blue, now you just colored it red.” You need some kind of way to share. I use this simplistic example it’s kind of a kid example because it’s like learning to share as a kid.

Like it’s my paper right now, I’m going to color it. When it’s your turn, you can color it. Obviously, that’s going to slow down the kids. They’re going to have to wait. At least you get some sense of order to the drawing. In some sense, it’s going to be more correct.

There’s one thing that you could do in this scenario to reduce that trade-off, reduce that cost of time, which is to reduce the amount of time you spend inside the lock. One thing that we do in Clojure is, because we’re dealing with immutable data structures, you can actually calculate the value that you will store in the locked variable ahead of time.

Normally what you would do if you wanted to operate on some data structure, let’s say it’s immutable data structure, but you want to do it safely. You want to do it correctly with multiple threads.

You would create a lock that all threads they have to acquire the lock before they can modify that data structure. One thread is going to have the lock at a time. You try to get the lock, if it’s already locked, you have to block, you just going to wait.

If it’s not logged, you get it. Now you can modify the data structure. When you’re done modifying it, you release. You might do 5, 10, or 100 operations on that data structure. The longer you go doing operations inside that lock, the more other threads are going to have to wait.

What you want to do is reduce the amount of time that you spend inside that lock. One thing that you could do is, instead of doing 10 operations inside the lock, you could, for instance, make a copy of the data structure without getting a lock or maybe you get a lock just long enough to make the copy.

Then you operate on that copy. It’s your copy. You can do whatever you want. You don’t need to lock. It’s not a shared resource. Then you grab the lock and swap out that data structure for the one that’s in there because you have the lock, you’re allowed to do that. You’re allowed to make modifications to it.

Now we do this in Clojure because we have immutable data structures that have built-in copy-on-write semantics. We don’t even think about the copy. We are making a copy. We can also guarantee that nothing else is modifying it while we’re reading it. Once you have a pointer to it, you’ll know it’s an immutable thing.

We have this built into the language. It’s one of the things that makes Clojure really nice. Now, here’s the thing, when you get that lock, you have to make sure that it hasn’t changed since you read it. You made this copy. You had to read the data structure to make the copy. Has the data structure changed in another thread since you read it?

This is what’s called compare-and-swap. You make this copy, you modify it. Then when you grab the lock, you have to check, “Hey has it changed since I read it last? Has some other thread changed it?” If it has, you got to start over. You’re like, “OK, I’ll read it again make a new copy and make modifications to this new thing.”

If it hasn’t changed, you can just set it right away. What this does is it lets you have a much smaller window of locked code, a much smaller mutual exclusion section. It’s another word, mutual exclusion and lock are pretty much the same. It just makes sure that you are spending much less time in that locked state.

There’s another trade-off here which is that you’re going to be doing work on your threads. This is instead of the threads simply blocking and not doing anything. It’s a trade-off that doesn’t matter. Except there will be a little bit more heat your threads are working, so they are taking up CPU from potentially other threads working, so there is some trade-off there.

I also want to talk about how locks are error-prone. There’s a lot of possibilities there for say, forgetting to acquire the lock before operating on it, before operating on this shared resource. There’s stuff like how do you make sure that you release the lock when you’re done?

You could forget to do that. If you’ve got multiple locks, you’ve got to lock them in the same order and every single thread. It becomes actually a pretty hard challenge and a lot of bugs in.

It’s one of the reasons why even with locks, people think multi-threaded code is very difficult. It’s because you’ve got these challenges now with reasoning about, “If I have this lock, what can I do? I need two locks. What order should I get them in?”

It’s actually a pretty hard thing to reason about once you’ve got a real sizable system. In Clojure, we don’t generally use locks themselves. We use primitives that are often built on top of locks.

The locking has been solved once and for all, and we can think at a higher level. We have something called an atom. It’s probably the most commonly used Clojure concurrency primitive, and it does that compare-and-swap.

It’s built on a Java class. I think the class is called atomic compare-and-swap. All it does is it stores a single immutable value and gives you an interface for modifying that value with retries if something else has modified it while you were modifying it.

It’s probably got locks down…I haven’t looked at the Java implementation, but it probably has locks down at the bottom so that you can do this compare-and-swap. Meaning has this value changed or has this pointer changed since I read it and made a copy?

If it has, I’m going to retry. It means I’m going to read it again and make a new thing. All of this is done with…You don’t have to deal with the locks. It’s a higher level of working.

Another common concurrency primitive is the queue. Instead of racing to grab the lock, and then whoever acquires lock first gets to go first, you could put the work into a queue and have a single thread operating on that shared resource at a time.

This is another way of sharing. It’s taking turns, or you’d line up. This works a lot better when there’s a lot more contention. Just imagine a crowd of people all trying to use the same…

I like to think of it like kids because kids don’t know the rules sometimes or they break the rules. They’re trying to share this toy. A few kids can work through the turn-taking. Whoever grabs it first, they get to play with it until they’re done. Then they pass it on to the next person, and then that person gets to play with it.

Somehow, they manage. Once it gets to 10, 20 kids, some kid is going to be like, “I haven’t played with it in like an hour. Please, can I?” This whole grabbing it, whoever gets it first isn’t going to work anymore.

Now, you have to line up. You get in line. Whoever’s at the front of the line, you get to play with it as long as you want. Then when you’re done, you want to play with it again you’ve got to go back at the end of the line.

It has a fairness to it that you can ensure that things happen in a certain order, and no one goes without playing with the toy. This queue, it changes the nature of the shared resource.

That toy, yes, it’s being shared, but only by one thread at a time. One thread runs that shared resource. Often, the way it’s implemented in software is you have one thread that’s called the worker thread.

Its tasks get put into the queue that operate on that shared resource in the worker thread. By putting something into the queue, you’re given a promise or a future that will have the result of your work being done.

The thread can continue doing some other stuff. Then when the worker thread is done, it will put the value into the feature. Then the original thread can continue working on it.

Really, only one thing is accessing that resource at a time. You could say it’s not sharing that resource anymore. It’s only one thread using it. What becomes shared is the queue.

You get smart people. They sit down. They harden this queue. They make sure it works concurrently, and all the locks are in place. That queue becomes the shared resource because the threads are going to be adding tasks to that queue so that they’re all adding them at the same time in parallel.

They’re sharing. There’s some discipline that’s going on with the locks for how things get put in in order. I just wanted to go over these different ways of getting at a higher level than locks, because locks are just very hard to reason about.

When we’re doing concurrency, it’s all about shared resources. Locks are…Imagine it’s like sharing a bathroom. If you have a couple of roommates, a little lock on the door of your bathroom can really help you share this bathroom safely.

You try to open the door, “Oh, it’s locked. I’ll come back in a little bit and try again. Someone must be in there.” If you had 20 people sharing a bathroom with locks, everyone is constantly knocking on the door.

What if someone takes a long time in there? It becomes a mess. It’s not enough, so you need some other system like a queue. Put your name on the board.

When someone leaves the bathroom, they’re going to call the person’s name on the board and whoever that is can come. You don’t have to just wait and stand in line. It’s also got an order to it so that things work out.

Recap. Locks do trade time for correctness, but correctness is something that you need. It’s not like you’re going to say, “Yeah, we’ll just be wrong half the time, but at least it’s fast.” You just never do that.

You do need to understand that once you go concurrent, meaning you have multiple threads sharing this resource, you are going to trade-off a little time. There’s no way around it.

Just like if you were sharing a bathroom, there are going to be times when you need to use the bathroom, but someone’s in it, so you have to wait. That’s just going to happen.

However, there is an efficiency there, because a lot of times the bathroom isn’t being used. Why have a bathroom per person? It doesn’t make sense. It also lets you scale up faster. Because the bathroom isn’t being used most of the time, you can add more people and — though there might be some bottleneck times — most of the time it will work out, if you’ve got a good system. It’s good for scaling.

The main thing you want to do with locks is reduce the amount of time inside the lock. That’s the main thing. If you can get people to go to the bathroom faster, for instance, you’ve got your toilet in there, then after you use the toilet, you go to the sink, you wash your hands, then you dry your hands, then you open the door.

You could say, “Well, we’re going to move the sink outside of the bathroom. Doesn’t need to be that private.” Now, we’re doing less with the locked door.

People can be using this shared resource more effectively. We’re doing less inside of the lock. I also want to say, because locks are so error-prone, they’re the main reason why people think concurrent programming is difficult. You should look into other primitives that are probably built on top of locks but have a better interface.

They’re less error-prone. They’re actually, I would say, less abstract and more specific. Locks are basically a general purpose tool. Just like in your house, a lock is a general purpose way to keep a door closed from one side. What you want is something much more specific to your bathroom and how to share a bathroom, or how to share a kitchen, etc.

When you get more specific, there’s less you have to think about. There’s less that you have to do as a programmer to make sure it works, less discipline. You can encode the discipline in code. That’s what these concurrency primitives do. I’d mentioned to you compare-and-swap. In Clojure, we call that an atom and queues.

Do yourself a favor. Go, research some concurrency primitives in your language. If you happen to be into Clojure, I have a big list of them for Clojure. Just search for Clojure concurrency. I am in the top, I’m not the number one right now, but I am up there in the top rankings. It’s a purely functional.tv article.

Do me a favor, please. If you found this valuable, you should subscribe because then you’ll get the next valuable episode that I’m doing.

I also like to get into discussions with people. If you have question, a comment, disagreement, agreement, I appreciate all of it. I read all of it. Email me at eric@lispcast.com. You can also message me on Twitter, just at mention me, I am at @ericnormand with a D.

I am also getting into LinkedIn. Find me on LinkedIn, Eric Normand, with a D.

Awesome. See you next time.

The post Do locks slow down your code? appeared first on LispCast.

Permalink

It’s okay to invent unusual things (lessons learned from history of science)

TL;DR: Learn from books or other people but let nobody say that what’s you doing is wrong just because it’s unusual.

Great things happen when people aren’t satisfied with what they have and willing to change it by inventing something new.

Sometimes you meet open-minded people whose reaction is great and they’re always there to help and support you.

But sometimes you don’t.

Fermat and Semmelweis

Pierre de Fermat was a mathematician who lived back in 17th century.

He was into number theory that seemed useless back then. Really, who needs prime numbers when all we need is agricultural calculations?

Nobody understood number theory back then, but after three centuries Fermat’s research paved the way to modern cryptography. We have HTTPS and blockchain because of him.

Okay, this was actually funny, but stubbornness can lead to really tragical things just like the story of Ignaz Semmelweis.

Semmelweis was the doctor who noticed the correlation between keeping hands clean and risk to be infected with fatal diseases.

He pretty much tried to teach doctors of that time to wash their hands.

His approach faced so much stubbornness and straight up hatred that he actually went insane and died in asylum. Sometimes when I read things like this I don’t really believe them but is so hard to argue with the facts.

My personal experience

Here are real quotes from real people I heard through my career. Those had been said to me and other developers I worked with:

Web does not need reactive programming. Nobody does that, so you shouldn’t.

Everybody use PHP now, we don’t need your fancy stuff here (about Clojure and functional programming)

You have to be a moron to write server-side code in JavaScript.

While straight up stubborn reactions to your vision are pretty much unavoidable, you can really push things forward and change the state of art forever.

Just like Quake developers invented the unusual way to calculate the square root unbelievably fast or Dan Abramov wasn’t satisfied with existing solutions and created Redux which later become a de-facto standard, you may be the one who the world needs.

Just carry on.

Patreon link

Permalink

Faster and Friendlier Routing with Reitit 0.3.0

We are happy to introduce a new version of reitit, a fast new data-driven routing library for Clojure/Script!

[metosin/reitit "0.3.0"]

The version is mostly backwards compatible, but contains a lot of big features, including a new route syntax and a new error formatter. As the last post was about version 0.2.0, here's a quick tour of the major changes since that.

New Route Syntax

Before 0.3.0, only colon-based path-parameters were supported. Parameters had to fill the whole space between slashes:

[["/users/:user-id"]
 ["/api/:version/ping"]]

The syntax is simple, but it doesn't allow the use of qualified path-parameters. The new wildcard routing in 0.3.0 support both the old syntax and a new bracket-syntax:

[["/users/{user-id}"]
 ["/api/{version}/ping"]]

Qualified keywords can be used, and parameters don't have to span whole segments between slashes:

[["/users/{domain/user-id}"]
 ["/files/file-{domain.file/number}.pdf"]]

More details in the route syntax documentation.

On Error

There has been a lot of complaints about error messages in Clojure. With Clojure 1.10.0, things are bit better, but the default error printing is still far from the friendly error messages of Elm and Eta.

In reitit, router creation time error messages have been rethought in 0.3.0. In case of error, an ExceptionInfo is thrown with a qualified error name and ex-data having all the relevant data. reitit.core/router catches all Exceptions and rethrows them with an enhanced error message, done by a configured error formatter.

The default formatter formats errors just like before. For more friendly errors, there is a new module reitit-dev, which contains an error message formatter based on fipp and expound (and the lovely 8bit colors from rebl-readline).

Below are few sample error messages.

On Route Conflict

(require '[reitit.core :as r])
(require '[reitit.dev.pretty :as pretty])

(r/router
  [["/ping"]
   ["/:user-id/orders"]
   ["/bulk/:bulk-id"]
   ["/public/*path"]
   ["/:version/status"]]
  {:exception pretty/exception})

Invalid Route Data

(require '[reitit.spec :as spec])
(require '[clojure.spec.alpha :as s])

(s/def ::role #{:admin :user})
(s/def ::roles (s/coll-of ::role :into #{}))

(r/router
  ["/api/admin" {::roles #{:adminz}}]
  {:validate spec/validate
   :exception pretty/exception})

The error formatter is developed and tested on macOS and currently only supports a dark theme. There have been discussion in #clj-commons Slack whether there could be a community-driven error formatter.

Performance

For routes with path-parameters, version 0.2.0 used a segment trie written in Clojure. It was already one of the fastest routers for Clojure, but still much slower compared to fast routers in other languages.

If search for better performance, the segment trie was ported into Java yielding 2x better performance. This was already good, but we didn't want to stop there.

For 0.3.0, the wildcard routing trie was fully rewritten, both with Clojure/Script and with Java. We used Profilers and flamegraphs (via clj-async-profiler) to find and eliminate the performance bottlenecks. On JVM, the trie is now 3x faster than the previous version, making it 6x faster than the one in 0.2.0.

I'll be talking about reitit in Clojure/North and will walk through the performance journey there in more detail.

According to the perf tests, reitit is now orders of magnitude faster than the other tested routing libraries and only less than twice as slow as httprouter, fast(est) router in Go. So, still some work to do ;)

The ClojureScript version is not as highly optimized as the Java version. If you have skills in tuning ClojureScript/JavaScript performance, feel free to contribute.

Spec Coercion

Spec coercion is now much more complete and works with plain clojure.spec Specs. Most of the changes have happened in spec-tools, which now also has a proper coercion guide. It currently supports only Spec1. There are also coercion guides on reitit side, for both core routing and for http/ring.

(require '[clojure.spec.alpha :as s])

(s/def ::x int?)
(s/def ::y int?)
(s/def ::total int?)
(s/def ::request (s/keys :req-un [::x ::y]))
(s/def ::response (s/keys :req-un [::total]))

(def math-routes
  ["/math"
   {:swagger {:tags ["math"]}}

   ["/plus"
    {:get {:summary "plus with query parameters"
           :parameters {:query ::request}
           :responses {200 {:body ::response}}
           :handler (fn [request]
                      (let [x (-> request :parameters :query :x)
                            y (-> request :parameters :query :y)]
                        {:status 200
                         :body {:total (+ x y)}}))}
     :post {:summary "plus with body parameters"
            :parameters {:body ::request}
            :responses {200 {:body ::response}}
            :handler (fn [request]
                       (let [x (-> request :parameters :body :x)
                             y (-> request :parameters :body :y)]
                         {:status 200
                          :body {:total (+ x y)}}))}}]])

See full example apps with spec coercion (and api-docs) for reitit-ring, reitit-http, reitit-frontend and reitit-pedestal.

Frontend

Small improvements, including a new polished api for controllers and HTML5 History routing works now with IE11. Both the Reagent template and kee-frame now default to reitit, which is really awesome.

Pedestal

Support for Pedestal was shipped already in 0.2.10 via new reitit-pedestal module. It allows the default Pedestal router to be swapped into reitit-http router. See the official documentation and an example app.

Final Words

0.3.0 was a big release, thanks to all contributors and pilot users! The full list of changes is found in the Changelog. We'll continue to actively develop the core libraries and the ecosystem around it, the roadmap is mostly laid out as Github Issues. Many issues are marked with help-wanted and good-first-pr, contributions are most welcome.

To discuss about reitit or to get help, there is a #reitit channel in the Clojurians Slack.

And last, but not least, I'll be giving a talk about reitit in Clojure/North on 20.4.2019. Looking forward to it.

Permalink

Copyright © 2009, Planet Clojure. No rights reserved.
Planet Clojure is maintained by Baishamapayan Ghose.
Clojure and the Clojure logo are Copyright © 2008-2009, Rich Hickey.
Theme by Brajeshwar.