Объединяем 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.
Если есть вопросы — пишите, в меру своих скромных познаний постараюсь ответить. Принимаю замечания, пожелания.
Спасибо за внимание!
Автор: newpy