
Объединяем Websockets, Lisp и функциональное программирование. Но как?
С помощью Clojure.
На Хабре существует достаточно статей — примеры приложений, использующих
вебсокеты (WebSocket, RFC), реализованные с помощью популярных языков и технологий. Сегодня я хотел бы показать пример простого веб-приложения, с использованием менее популярных, но от этого не менее хороших, технологий и маленькой (~90kB JAR with zero dependencies and ~3k lines of (mostly Java) code) клиент/сервер библиотеки http-kit.
Возможный побочный эффект — (не цель) развеяние мифа о сложности написания современных приложений используя Lisp и функциональное программирование.
Эта статья — не ответ другим технологиям, и не их сравнение. Эта проба пера продиктована исключительно моей личной привязанностью к Clojure и давним желанием попробовать написать.
Встречайте дружную компанию:
- В главной роли Clojure
- Жанр: FP (Functional programming)
- Клиент/сервер: http-kit
- Инструментарий: lein (leiningen) — утилита для сборки(build tool), менеджер зависимостей.
- и другие
Я не хотел бы делать экскурс в Clojure и Lisp, стек и инструментарий, лучше буду делать короткие ремарки, и оставлять комментарии в коде, поэтому приступим:
lein new ws-clojure-sample
Ремарка: leiningen позволяет использовать шаблоны для создания проекта, его структуры и задания стартовых "настроек" или подключения базовых библиотек. Для ленивых: можно создать проект с помощью одного из таких шаблонов так:
lein new compojure ws-clojure-sample
где compojure — библиотека для маршрутизации(роутинга) работающая с Ring. Мы же сделаем это вручную (наша команда тоже реализует/использует шаблон, называемый, default)
В результате выполнения будет сгенерирован проект, имеющий следующую структуру:

В дальнейшем, для сборки проекта и управления зависимостями, leiningen руководствуется файлом в корне проекта project.clj.
На данный момент у нас он принял следующий вид:
project.clj
(defproject ws-clojure-sample "0.1.0-SNAPSHOT" :description "FIXME: write description" :url "http://example.com/FIXME" :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html"} :dependencies [[org.clojure/clojure "1.8.0"]])
Давайте сразу добавим необходимые нам зависимости в раздел dependencies
Ремарка: ключевое слово(clojure keyword) :dependencies.
и укажем точку входа(пространство имен) в наше приложение :main
project.clj
(defproject ws-clojure-sample "0.1.0-SNAPSHOT" :description "FIXME: write description" :url "http://example.com/FIXME" :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html"} :dependencies [[org.clojure/clojure "1.8.0"] [http-kit "2.2.0"] ;; Подключаем http-kit [compojure "1.6.0"] ;; Подключаем compojure (роутинг/маршрутизация) [ring/ring-defaults "0.3.1"] ;; Джентльменский набор middleware по умолчанию [org.clojure/data.json "0.2.6"]] ;; Пригодится для работы с JSON :profiles ;; Профили для запуска lein with-profile <имя профиля> {:dev ;; Профиль разработки {:dependencies [[javax.servlet/servlet-api "2.5"] ;; пригодится если вы будете устанавливать ring/ring-core [ring/ring-devel "1.6.2"]]}} ;; пригодится для горячей перезагрузки :main ws-clojure-sample.core) ;; пространство имен в котором находится функция -main(точка входа в приложение)
Ремарка: middleware ring-defaults
Перейдем, собственно, к самой точке входа в приложение. Откроем файл core.clj
core.clj
(ns ws-clojure-sample.core) (defn foo "I don't do a whole lot." [x] (println x "Hello, World!"))
и заменим сгенерированную функцию foo, на более понятную и общепринятую -main. Далее импортируем в текущее пространство имен необходимые нам компоненты. Собственно нам нужен, в первую очередь, сервер, далее маршруты, и наши middleware. В роли сервера у нас выступает http-kit и его функция run-server.
core.clj
(ns ws-clojure-sample.core (:require [org.httpkit.server :refer [run-server]] ;; http-kit server [compojure.core :refer [defroutes GET POST DELETE ANY]] ;; defroutes, и методы [compojure.route :refer [resources files not-found]] ;; маршруты для статики, а также страница not-found [ring.middleware.defaults :refer :all])) ;; middleware
Ремарка: данный код, является совершенно валидным кодом на Clojure, и одновременно структурами данных самого языка. Это свойство языка называется гомоиконностью
Читать, на мой взгляд, тоже просто, и не требует особых пояснений.
Серверу, в качестве аргумента, необходимо передать функцию обработчик и параметры сервера
примерно так:
(run-server <Обработчик(handler)> {:port 5000})
В качестве этого обработчика будет выступать функция(на самом деле макрос) маршрутизатор defroutes которому мы дадим имя, и которая в свою очередь будет вызывать, в зависимости от маршрута, уже непосредственный обработчик. И все это мы еще можем обернуть и приправить нашим middleware.
Ремарка: middleware ведет себя как декоратор запросов.
core.clj
(ns ws-clojure-sample.core (:require [org.httpkit.server :refer [run-server]] ;; http-kit server [compojure.core :refer [defroutes GET POST DELETE ANY]] ;; defroutes, и методы [compojure.route :refer [resources files not-found]] ;; маршруты для статики, и not-found [ring.middleware.defaults :refer :all])) ;; middleware (defroutes app-routes (GET "/" [] index-page) ;; Нам нужна будет главная страница для демонстрации (GET "/ws" [] ws-handler) ;; здесь будем "ловить" веб-сокеты. Обработчик. (resources "/") ;; директория ресурсов (files "/static/") ;; префикс для статических файлов в папке `public` (not-found "<h3>Страница не найдена</h3>")) ;; все остальные, возвращает 404) (defn -main "Точка входа в приложение" [] (run-server (wrap-defaults #'app-routes site-defaults) {:port 5000}))
Итак, теперь у нас есть точка входа в приложение, которая запускает сервер, который имеет маршрутизацию. Нам не хватает здесь двух функций обработчиков запросов:
- index-page
- ws-handler
Начнем с index-page.
Для этого в директории ws_clojure_sample создадим папку views и в ней файл index.clj. Укажем получившееся пространство имен,
и создадим нашу заглавную страницу index-page:
views/index.clj
(ns ws-clojure-sample.views.index) (def index-page "Главная")
На этом можно было бы и закончить. По сути тут вы можете строкой задать обычную HTML страницу. Но это некрасиво. Какие могут быть варианты? Неплохо бы было вообще использовать какой-нибудь шаблонизатор. Нет проблем. Например вы можете использовать Selmer. Это быстрый шаблонизатор, вдохновленный шаблонизатором Django. В этом случае, представления будут мало отличаться от таковых в Django проекте. Поклонникам Twig, или Blade тоже все будет знакомо.
Я же пойду другим путем, и выберу Clojure. Буду писать HTML на Clojure. Что это значит — сейчас увидим.
Для этого нам понадобится небольшая (это относится к большинству Clojure библиотек) библиотека hiccup. В файле project.clj в :dependencies добавим [hiccup "1.0.5"].
Ремарка: к слову автор, у библиотек compojure и hiccup, и многих других ключевых библиотек в экосистеме Clojure, один и тот же, его имя James Reeves, за что ему большое спасибо.
После того как мы добавили зависимость в проект, необходимо импортировать ее содержимое в пространство имен нашего представления src/ws_clojure_sample/views/index.clj и написать наш HTML код. Дабы ускорить процесс я сразу приведу содержимое views/index.clj целиком
(а вы удивляйтесь что это наблюдайте):
views/index.clj
(ns ws-clojure-sample.views.index (:use [hiccup.page :only (html5 include-css include-js)])) ;; Импорт нужных функций hiccup в текущее пространство имен ;; Index page (def index-page (html5 [:head (include-css "https://unpkg.com/bootstrap@3.3.7/dist/css/bootstrap.min.css")] [:body {:style "padding-top: 50px;"} [:div.container [:div.form-group [:input#message.form-control {:name "message" :type "text"}]] [:button.btn.btn-primary {:name "send-btn"} "Send"]] [:hr] [:div.container [:div#chat]] (include-js "js/ws-client.js") (include-js "https://unpkg.com/jquery@3.2.1/dist/jquery.min.js") (include-js "https://unpkg.com/bootstrap@3.3.7/dist/js/bootstrap.min.js")]))
Наше представление готово, и думаю не нуждается в комментариях. Создали обычный <input name="message" type="text"/> и кнопку Send. С помощью этой нехитрой формы мы будем отправлять сообщеия в чат канал. Осталось не забыть импортировать index-page в пространство имен core. Для этого возвращаемся в src/ws_clojure_sample/core.clj и дописываем в директиву :require строку [ws-clojure-sample.views.index :refer [index-page]].
Заодно давайте и основной обработчик ws-handler пропишем, который следом нам необходимо создать.
core.clj
... [ws-clojure-sample.views.index :refer [index-page]] ;; Добавляем представление index-page [ws-clojure-sample.handler :refer [ws-handler]])) ;; Предстоит создать ws-handler (defroutes app-routes (GET "/" [] index-page) (GET "/ws" [] ws-handler) ;; Создать handler.clj
Большинство методов и абстракций для работы с веб-сокетами/long-polling/stream, предоставляет наш http-kit сервер, возможные примеры и вариации легко найти на сайте библиотеки. Дабы не городить огород, я взял один из таких примеров и немного упростил. Создаем файл src/ws_clojure_sample/handler.clj, задаем пространство имен и импортируем методы with-channel, on-receive, on-closeиз htpp-kit:
handler.clj
(ns ws-clojure-sample.handler (:require [org.httpkit.server :refer [with-channel on-receive on-close]] ;; Импорт из http-kit [ws-clojure-sample.receiver :refer [receiver clients]])) ;; Предстоит создать ;; Главный обработчик (handler) (defn ws-handler "Main WebSocket handler" [request] ;; Принимает запрос (with-channel request channel ;; Получает канал (swap! clients assoc channel true) ;; Сохраняем пул клиентов с которыми установлено соединение в атом clients и ставим флаг true (println channel "Connection established") (on-close channel (fn [status] (println "channel closed: " status))) ;; Устанавливает обработчик при закрытии канала (on-receive channel (get receiver :chat)))) ;; Устаналивает обработчик данных из канала (его создадим далее)
swap! clients— меняет состояние атома clients, записывает туда идентификатор канала в качестве ключа и флаг в качестве значения. Зададим далее.with-channel— получает каналon-close— Устанавливает обработчик при закрытии каналаon-receive— Устаналивает обработчик данных из канала(get receiver :chat)— это нам предстоит.
Давайте определим обработчик для получения данных из канала on-receive и наших clients. Создадим src/ws_clojure_sample/receiver.clj, как обычно укажем наше пространство имен.
receiver.clj
(ns ws-clojure-sample.receiver) (def clients (atom {})) ;; наши клиенты
Поскольку нужен наглядный пример, и обработчиков может быть несколько, сперва покажу на примере чата, и назову его chat-receiver.
(defn chat-receiver) [data] ;; Принимает данные (для чата это сообщение из *input*) (doseq [client (keys @clients)] ;; каждому клиенту (выполняет для каждого элемента последовательности и дает ему alias client) (send! client (json/write-str {:key "chat" :data data}))) ;; посылает json-строку с ключом "chat" и данными "data" которые и были получены
send! и json/write-str надо импортировать в текущее пространство имен.
receiver.clj
(ns ws-clojure-sample.receiver (:require [clojure.data.json :as json] [org.httpkit.server :refer [send!]]))
А что если мы захотим не чат? Или не только чат, а например принимать данные из внешнего источника и отправлять в сокеты? Я придумал хранитель обработчиков, ну о-о-очень сложный.
(def receiver {:chat chat-receiver})
Для примера я сделал такой "ресивер" для отправки-получения данных, чтобы можно было поиграть не только с чатом, поэтому добавим в хранитель обработчиков пример data-receiver. Пусть будет.
(def receiver {:chat chat-receiver :data data-receiver})
Просто приведу его код:
(def urls ["https://now.httpbin.org" "https://httpbin.org/ip" "https://httpbin.org/stream/2"]) (defn data-receiver "Data receiver" [data] (let [responses (map #(future (slurp %)) urls)] ;; отсылаю запросы (в отдельных потоках) по списку urls (doall (map (fn [resp] ;; бегу по всем ответам (doseq [client (keys @clients)] ;; бегу по всем сокет-клиентам (send! client @resp))) responses)))) ;; и рассылаю эти данные всем сокет-клиентам
Теперь мы можем выбирать какой из них запускать при получении данных из канала, и как будет работать приложение, просто меняя ключ:
(on-receive channel (get receiver :chat :data)) ;; можем менять местами на :data или добавить как параметр, в случае если :chat не будет найден.
С серверной частью всё.
Осталась клиентская. А на клиенте, в коде представления, вдруг вы заметили, как я подключал файл ws-client.jsкоторый живет в директории resources/public/js/ws-client.js
(include-js "js/ws-client.js")
Именно он и отвечает за клиентскую часть. Поскольку это обычный JavaScript, то я просто приведу код.
Ремарка: не могу не отметить, что клиентский код, вместо javascript, можно было писать на Clojure. Если говорить точнее, то на ClojureScript. Если пойти еще дальше, то фронтенд можно сделать, например, с помощью Reagent.
let msg = document.getElementById('message'); let btn = document.getElementsByName('send-btn')[0]; let chat = document.getElementById('chat'); const sendMessage = () => { console.log('Sending...'); socket.send(msg.value); } const socket = new WebSocket('ws://localhost:5000/ws?foo=clojure'); msg.addEventListener("keyup", (event) => { event.preventDefault(); if (event.keyCode == 13) { sendMessage(); } }); btn.onclick = () => sendMessage(); socket.onopen = (event) => console.log('Connection established...'); socket.onmessage = (event) => { let response = JSON.parse(event.data); if (response.key == 'chat') { var p = document.createElement('p'); p.innerHTML = new Date().toLocaleString() + ": " + response.data; chat.appendChild(p); } } socket.onclose = (event) => { if (event.wasClean) { console.log('Connection closed. Clean exit.') } else { console.log(`Code: ${event.code}, Reason: ${event.reason}`); } } socket.onerror = (event) => { console.log(`Error: ${event.message}`); socket.close(); }
Если запустить этот код из корня проекта с помощью leiningen командой lein run, то
проект должен скомпилироваться, и пройдя по адресу http://localhost:5000, можно увидеть
тот самый <input> и кнопку Send. Если открыть две таких вкладки и в каждой послать сообщение, то можно убедиться что простейший чат работает. При закрытии вкладки, срабатывает наш метод on-close. Аналогично можно поиграть с данными. Они должны просто выводиться в браузере в консоль.
В итоге получилось простое, минималистичное приложение (62 строчки кода вместе с импортами), дающее представление о том как писать веб-приложения на современном диалекте лиспа, при этом совершенно спокойно можно писать асинхронный код, распараллеливать задачи и использовать легкие, современные, простые решения для веба. И все это делают мои 62 убогие строчки кода!
На прощание интересный факт: не знаю обратили ли вы внимание, но при подключении в проект clojure библиотек, большинство из них имеют "низкую" версионность, столь непривычную для хороших стабильных проектов, например [ring/ring-defaults "0.3.1"] или [org.clojure/data.json "0.2.6"]. Причем, обе библиотеки используются практически повсеместно. Но для экосистемы Clojure такое версионирование довольно обыденное явление. Связано это прежде всего с высокой стабильностью кода написанного на Clojure. Хотите верьте, как говорится, хотите нет.
И еще немного про http-kit:
http-kit это не только сервер, библиотека предоставляет и http-client API. И клиент, и сервер удобны в использовании, минималистичны, при этом обладают хорошими возможностями (600k concurrent HTTP connections, with Clojure & http-kit).
Весь код приложения гиганта доступен на Github.
Если есть вопросы — пишите, в меру своих скромных познаний постараюсь ответить. Принимаю замечания, пожелания.
Спасибо за внимание!
