Статьи

Haskell, WebSockets и D3.js для анализа и визуализации данных

Это первая из серии публикаций, где я расскажу о простом приложении, которое позволяет вам анализировать и представлять данные интересными способами. Я напишу хороший клиент AngularJS с подключением WebSocket к бэкэнду Haskell.

Если вы нетерпеливы, возьмите исходный код с https://github.com/janm399/hwsexp ; и запустите, cabal runчтобы запустить сервер WebSocket. Чтобы увидеть (на данный момент примитивный) пользовательский интерфейс, откройте web/numbers.htmlв современном браузере. Вы увидите (в стиле фанк и трогательно) представление HTML.

$ cabal run
Preprocessing library hwssexp-0.1.0.0...
In-place registering hwssexp-0.1.0.0...
Preprocessing executable 'ws' for hwssexp-0.1.0.0...
Server is running

результат

Код на Haskell

Наш серверный код на Haskell прослушивает все локальные адреса на порту 9160 для соединений WebSocket. Мы также хотели бы поддерживать состояние, которое представляет собой список подключенных сеансов. В течение срока службы сервера мы будем изменять это состояние, которое разделяется между потоками.

-- |The main entry point for the WS application
main :: IO ()
main = do
  putStrLn "Server is running"
  state < - newMVar newServerState
  WS.runServer "0.0.0.0" 9160 $ application state

Оставляя в putStrLnстороне очевидное , мы создаем состояние MVar ServerState, которое является состоянием при запуске сервера. Затем мы используем ServerStateзначение, когда затем (вспомним, как работает Haskell!) Запускается сервер WebSocket. Состояние, которое сохраняет наш сервер, представляет собой список запросов, которые пользователь отправил вместе с соединением WebSocket, на которое сервер отправит результаты. Это дает нам хорошее место для определения этих типов вместе с newServerStateфункцией.

-- |Client is a combination of the statement that we're running and the
--  WS connection that we can send results to
type Client = (Text, WS.Connection)

-- |Server state is simply an array of active @Client@s
type ServerState = [Client]

-- |Named function that retuns an empty @ServerState@
newServerState :: ServerState
newServerState = []

Большой; Чтобы завершить картину, давайте добавим функции, которые позволяют нам добавлять и удалять клиентов.

-- |Adds new client to the server state
addClient :: Client      -- ^ The client to be added
          -> ServerState -- ^ The current state
          -> ServerState -- ^ The state with the client added
addClient client clients = client : clients

-- |Removes an existing client from the server state
removeClient :: Client      -- ^ The client being removed
             -> ServerState -- ^ The current state 
             -> ServerState -- ^ The state with the client removed
removeClient client = filter ((/= fst client) . fst)

Теперь у нас есть весь вспомогательный код; все, что нам нужно сделать, это обеспечить реализацию applicationфункции; эта функция представляет то, что мы называем контроллером в старом мире. Он получает запросы и, как побочный эффект, может изменять состояние сервера и давать ответы.

-- |The handler for the application's work
application :: MVar ServerState -- ^ The server state
            -> WS.ServerApp     -- ^ The server app that will handle the work
application state pending = do
  conn < - WS.acceptRequest pending
  query <- WS.receiveData conn
  clients <- liftIO $ readMVar state
  let client = (query, conn)
  modifyMVar_ state $ return . addClient client
  perform state client

Сначала мы принимаем запрос (мы принимаем любые запросы WS), давая нам Connectionзначение; Затем мы получаем данные, которые клиент отправил нам String. Наконец, мы извлекаем ServerStateзначение из общего ресурса MVar ServerState.

Мы создаем Clientзначение: кортеж, содержащий запрос и соединение WebSocket для этого запроса. Довольно сложная линия modifyMVar_ state $ return . addClient client. Мы модифицируем общий доступ ServerState. Если мы расширим выражение, исключив бессмысленный стиль, мы получим

modifyMVar_ state (\s' -> return (addClient client s'))

Мы можем исключить s'в (\s' -> return ... s')уравнении: return . addClient clientэто одно и то же: функция, которая принимает ServerStateи возвращает IO ServerState. Отлично. Наконец, мы можем удалить заключительные скобки, используя ($)функцию. Это дает нам финал

modifyMVar_ state $ return . addClient client

Это оставляет нам только последнее выражение; тот, который на самом деле делает работу. (Мы будем держать эту часть довольно легкой на данном этапе, но улучшим ее в течение следующих нескольких постов; и поверьте мне, есть много улучшений!)

-- |Performs the query on behalf of the client, 
--  cleaning up after itself when the client disconnects
perform :: MVar ServerState -- ^ The server state
        -> Client           -- ^ The query to perform and the conn for results
        -> IO ()            -- ^ The output
perform state client@(query, conn) = handle catchDisconnect $
  forever $ do
    numbers < - replicateM 100 ((`mod` 100) <$> randomIO :: IO Int)
    WS.sendTextData conn (T.pack $ show numbers)
    threadDelay 1000000
  where
    catchDisconnect :: SomeException -> IO ()
    catchDisconnect _ = liftIO $ modifyMVar_ state $ return . removeClient client

Рассекая код, мы оборачиваем вечно повторяющиеся вычисления в обработчик исключений. Это дает нам основную форму кода. В блоке forever мы генерируем 100 случайных чисел, каждое в диапазоне от 0 до 100, которые мы отправляем в прослушивающий WebSocket. Затем (о человечество! Подробнее об этом в будущем.) Мы спим в течение 1 секунды, а затем повторяем. Блок catch обрабатывает любые исключения, удаляя клиента из общего ресурса сервера ServerState.

Веб-приложение

Продолжим, давайте взломаем хорошее веб-приложение AngularJS. Мы хотим подключиться к нашему серверу на Haskell, а затем отобразить числа, которые мы получаем, в текстовом поле, а также — используя D3.js — в красивой диаграмме.

<!doctype html>
<html>
<head>
    <title>Number cruncher</title>
    ...
</head>
<body>
<div ng-app="numbers.app" ng-controller="NumbersCtrl">
    <tabs>
        <pane title="Raw">
            <h3>Raw data</h3>
            <pre>{{numbers}}</pre>
        </pane>
        <pane title="Canvas">
            <h3>Visual representation</h3>
            <barchart2d data="{{numbers}}"/>
        </pane>
    </tabs>
</div>
</body>
</html>

И это все, что нужно сделать — хорошо, если вы решите игнорировать магию AngularJS, в частности, NumbersCtrlконтроллер и barchart2dкомпонент. Стоит изучить их чуть подробнее, начиная с NumbersCtrl.

angular.module('numbers.app', ['d3.directives', 'numbers.directives'])
  .controller('NumbersCtrl', ['$scope', function($scope) {
    function createWebSocket(path) {
      var host = window.location.hostname;
      if (host == '') host = 'localhost';
      var uri = 'ws://' + host + ':9160' + path;

      var Socket = "MozWebSocket" in window ? MozWebSocket : WebSocket;
      return new Socket(uri);
    }

    $scope.numbers = {};

    var socket = createWebSocket('/'); 
    socket.onopen = function() {
       // we'll have that in the next session
       socket.send("even 0-100 every 1s"); 
    };
    socket.onmessage = function(e) {
       $scope.$apply(function() {
         $scope.numbers = e.data;
       });
    };
  }]);

Это код нашего приложения AngularJS. Это зависит d3.directivesи от numbers.directivesмодулей; Эти модули содержат директивы (компоненты Think) для диаграмм D3 и нашего элемента управления вкладками. Директивы tab являются необработанным примером AngularJS, поэтому давайте рассмотрим компоненты D3. Мы разделили его на два модуля: один, который предоставляет d3услугу (используя D3 JavaScript), а затем модуль, который предоставляет компоненты.

// creates the d3.core module, which contains the d3Service
angular.module('d3.core', [])
  // creates d3Service by injecting the D3JS JavaScript to the document
  .factory('d3Service', ['$document', '$q', '$rootScope',
    function($document, $q, $rootScope) {
      ...
      return {
        d3: ... // the d3 namespace
      };
  }]);

Захватите код с https://github.com/janm399/hwsexp для получения полной информации. d3.directivesМодуль обеспечивает barchart2dдля нас использование:

// creates the d3.core module, which contains the various D3 charts
angular.module('d3.directives', ['d3.core'])
  .directive('barchart2d', ['d3Service', function(d3Service) {
    return {
      restrict: 'E',
      transclude: true,
      scope: { data: '@' },
      template: '<div class="barchart2d" ng-transclude=""></div>',
      replace: true,
      link: function(scope, element, attrs) {
              d3Service.d3().then(function(d3) {
                function fmt(element, x) {
                  element.style("width", function(d) { return x(d) + "px"; })
                         .text(function(d) { return d; });
                }

                attrs.$observe('data', function(rawValue) {
                  var data = JSON.parse(rawValue);

                  var x = d3.scale.linear()
                      .domain([0, d3.max(data)])
                      .range([0, 420]);

                  var p = d3.select(element[0]).selectAll("div").data(data);
                  fmt(p.enter().append("div"), x);
                  fmt(p.transition(), x);
                  p.exit().remove();
                });
              });
            }
    };
  }]);

Опять же, это базовая диаграмма D3, небольшая галочка включает использование attrs.$observeдля подключения к изменениям данной модели.

Резюме

И там у вас есть это. Вы можете запустить сервер WebSocket на основе Haskell, а затем иметь современное веб-приложение, которое отображает выходные данные, отправленные сервером Haskell. Теперь, когда у нас есть основные строительные блоки, мы будем добавлять больше функций, особенно для анализа запроса, который вводят пользователи, а затем его выполнения.