Alt text


Объединяем 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)


В результате выполнения будет сгенерирован проект, имеющий следующую структуру:


Alt text


В дальнейшем, для сборки проекта и управления зависимостями, 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
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
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
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
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
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
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})

Просто приведу его код:


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.


ws-client.js
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.


Если есть вопросы — пишите, в меру своих скромных познаний постараюсь ответить. Принимаю замечания, пожелания.


Спасибо за внимание!

Комментарии (4)


  1. shybovycha
    09.10.2017 11:13

    Ремарка: ключевое слово(clojure keyword) :dependencies.

    Насколько я помню и понимаю, в данном контексте это не "ключевое слово" (для кложуры и лисп-подобных языков вообще не характерно понятия ключевых слов — есть функции, макросы ну и конструкции вроде списков, векторов, хешмапов...), а скорее просто ключ хешмапы =)


    Аналогией в java было бы


    Map<String, Integer> m = new HashMap<>();
    
    m.put("some", -2);

    Здесь "some" — и есть этот самый ключ. А new — ключевое слово.


    Самое близкое к ключам кложуры, что приходит на ум — символы в руби. Они выполняют схожую роль.


    Ремарка: middleware ring-defaults

    Вообще не понял для чего эта ремарка.


    В общей сложности получилось немного сумбурно и в основном — просто набор кусков кода для тех, "кто в теме". Задача с самого начала не особо ясна, каждый шаг тоже не особо понятно для чего делается...


    1. newpy Автор
      09.10.2017 13:15
      +1

      Спасибо, учту. В следующий раз постараюсь озвучить цель вначале более прозрачно, и вынести наверх.

      просто набор кусков кода для тех, «кто в теме»

      вероятно вы правы, это скорее для тех «кто в теме». Собственно вначале я упомянул про это, сказав что не хотел бы делать экскурс. Не ставил задачу писать tutorial. Но я все равно приму к сведению, спасибо.

      а скорее просто ключ хешмапы

      Отображения (hash-maps) могут использовать в качестве ключей любые хэшируемые значения
      (def stringmap {"a" 1, "b" 2, "c" 3})

      Самое близкое к ключам кложуры, что приходит на ум — символы в руби

      Если говорить о том что это, то это прежде всего структура данных языка. Если хотите, то по смыслу это напоминает строковые контанты. Но и разница все таки есть. В Clojure есть именно keywords, и есть symbols. Скажем
      (def :kword "25") ;; CompilerException java.lang.RuntimeException: First argument to def must be a Symbol
      , пример с символом
      
      (def kword "25") ;; #'user/kword - валидный код namespace/symbol
      (println kword) ;; "25"
      (println :kword) ;; :kword
      ;; keywords, в отличие от symbols не привязаны к пространствам имен 
      ;; пока это не сделать явно.
      (namespace :foo) ;; nil
      (namespace ::foo) ;; "user"
      (class :kword) ;; clojure.lang.Keyword
      (symbol? :kword) ;; false
      ;; могут использоваться в роли функций
      (def keymap {:a 1 :b 2})
      (:a keymap) ;; 1
      

      Использовал термин «ключевое слово» только лишь в контексте перевода «keyword» и возможно этим, просто сбил вас с толку. Не хотелось «сыпать» англицизмами. Это примерно как вы выше использовали «хеш-мапы», а я употребил термин «отображения».
      Но еще раз спасибо за замечания.


      1. shybovycha
        09.10.2017 13:31

        Спасибо за обоснованный ответ! Действительно, в большинстве случаев — привычная терминология, не более.


    1. e_aksenov
      09.10.2017 13:16
      +1

      Совершенно верно.
      В Clojure keyword — это специфичный тип данных, который реализуется сам в себя (Keywords are symbolic identifiers that evaluate to themselves), и их действительно очень удобно использовать в качестве ключа в хешмапах (и не только). Так что технически здесь keyword — ключевое слово, но используемый термин не соответствует понятию ключевых слов в других языках. :)