Доброго времени суток!
На Хабре существует ряд статей - примеры приложений, использующих веб-сокеты (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)
В результате выполнения будет сгенерирован минимальный скелет проекта, с уже готовой структурой.
[ws-clojure-sample]
|- README.md
|- project.clj
|- resources
|- src
|- test
В дальнейшем, для сборки проекта и управления зависимостями, 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
Также укажем точку входа в наше приложение :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
[http-kit "2.2.0"]
;; Подключаем compojure (роутинг/маршрутизация)
[compojure "1.6.0"]
;; Джентльменский набор middleware по умолчанию
[ring/ring-defaults "0.3.1"]
;; Пригодится для работы с JSON, еще можно использовать cheshire
[org.clojure/data.json "0.2.6"]]
;; Профили для запуска lein with-profile <имя профиля>
:profiles
;; Профиль разработки
{:dev
{:dependencies [;; пригодится если вы будете устанавливать ring/ring-core
[javax.servlet/servlet-api "2.5"]
;; пригодится для горячей перезагрузки
[ring/ring-devel "1.6.2"]]}}
;; пространство имен в котором находится функция -main(точка входа в приложение)
:main ws-clojure-sample.core)
Ремарка: 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 ;; http-kit server
[org.httpkit.server :refer [run-server]]
;; defroutes, и методы
[compojure.core :refer [defroutes GET POST DELETE ANY]]
;; маршруты для статики, а также страница not-found
[compojure.route :refer [resources files not-found]]
;; middleware
[ring.middleware.defaults :refer :all]))
Ремарка: данный код, является совершенно валидным кодом на Clojure, и одновременно структурами данных самого языка. Это свойство языка называется гомоиконностью Читать, на мой взгляд, тоже просто, и не требует особых пояснений.
Серверу, в качестве аргумента, необходимо передать функцию обработчик и параметры сервера примерно так:
(run-server <Обработчик(handler)> {:port 5000})
В качестве этого обработчика будет выступать функция(на самом деле макрос) маршрутизатор defroutes
которому мы дадим имя, и которая в свою очередь будет вызывать, в зависимости от маршрута,
уже непосредственный обработчик. И все это мы еще можем обернуть и приправить нашим middleware.
Ремарка: middleware ведет себя как декоратор запросов.
core.clj
(ns ws-clojure-sample.core
(:require ;; http-kit server
[org.httpkit.server :refer [run-server]]
;; defroutes, и методы
[compojure.core :refer [defroutes GET POST DELETE ANY]]
;; маршруты для статики, и not-found
[compojure.route :refer [resources files not-found]]
;; middleware
[ring.middleware.defaults :refer :all]))
(defroutes app-routes
;; Нам нужна будет главная страница для демонстрации
(GET "/" [] index-page)
;; здесь будем "ловить" веб-сокеты. Обработчик.
(GET "/ws" [] ws-handler)
;; директория ресурсов
(resources "/")
;; префикс для статических файлов в папке `public`
(files "/static/")
;; все остальные, возвращает 404)
(not-found "<h3>Страница не найдена</h3>"))
(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
;; Импорт нужных функций hiccup в текущее пространство имен
(:use [hiccup.page :only (html5 include-css include-js)]))
;; 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
...
;; Добавляем представление index-page
[ws-clojure-sample.views.index :refer [index-page]]
;; Предстоит создать ws-handler
[ws-clojure-sample.handler :refer [ws-handler]]))
(defroutes app-routes
(GET "/" [] index-page)
;; Создать handler.clj
(GET "/ws" [] ws-handler)
Большинство методов и абстракций для работы с веб-сокетами/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 ;; Импорт из http-kit
[org.httpkit.server :refer [with-channel on-receive on-close]]
;; Предстоит создать
[ws-clojure-sample.receiver :refer [receiver clients]]))
;; Главный обработчик (handler)
(defn ws-handler
"Main WebSocket handler"
;; Принимает запрос
[request]
;; Получает канал
(with-channel request channel
;; Сохраняем пул клиентов с которыми установлено соединение в атом clients и ставим флаг true
(swap! clients assoc channel 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)
;; Принимает данные (для чата это сообщение из *input*)
[data]
;; каждому клиенту (выполняет для каждого элемента последовательности и дает ему alias client)
(doseq [client (keys @clients)]
;; посылает json-строку с ключом "chat" и данными "data" которые и были получены
(send! client (json/write-str {:key "chat" :data 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]
;; отсылаю запросы (в отдельных потоках) по списку urls
(let [responses (map #(future (slurp %)) urls)]
;; бегу по всем ответам
(doall (map (fn [resp]
;; бегу по всем сокет-клиентам
(doseq [client (keys @clients)]
;; и рассылаю эти данные всем сокет-клиентам
(send! client @resp))) responses))))
Теперь мы можем выбирать какой из них запускать при получении данных из канала, и как будет работать приложение, просто меняя ключ:
handler.clj
;; можем менять местами на :data или добавить как параметр, в случае если :chat не будет найден.
(on-receive channel (get receiver :chat :data))))
С серверной частью всё.
Осталась клиентская. А на клиенте, в коде представления, вдруг вы заметили, как я подключал файл 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
.
Аналогично можно поиграть с данными. Они должны просто выводиться в браузере в консоль.
Весь код приложения доступен на Github.
Про http-kit:
это не только сервер, библиотека предоставляет и http-client API. И клиент, и сервер удобны в использовании, минималистичны, при этом обладают хорошими возможностями (600k concurrent HTTP connections, with Clojure & http-kit).
Готов ответить на вопросы в меру своих скромных познаний.
Спасибо за внимание! Принимаю любые замечания, пожелания (это моя первая публикация) (стиль изложения, оформление, код, грамматика).