Впервые о Meteor мир услышал в декабре 2011 года. Говорят, что он заставляет по-новому взглянуть на то, как устроено, как разрабатывается и как работает веб-приложение. Чтобы проверить эти утверждения на практике, мы попробуем сделать на нем некоторые элементы многопользовательской карточной игры. Не будем следить за кросс-браузерностью и правилами самой игры, наша задача — оценить, какие возможности при разработке дает Meteor.

Подготовка

Для нашей колоды мы возьмем бесплатный набор векторных игральных карт в формате SVG: SVG-Cards. Каждый рисунок в нем представляет отдельную группу SVG со своим именем, состоящим из силы (достоинства) карты (rank) и ее масти (suit):

<g id="king_spade"> ... </g>

Чтобы карты не отрисовывались каждый раз векторами и просто для удобства работы с ними из JavaScript сконвертируем картинки из единого SVG-файла в формат PNG, для чего воспользуемся редактором Inkscape и сценарием типа такого:

declare -a suit=(club diamond heart spade)
declare -a rank=(1 2 3 4 5 6 7 8 9 10 king queen jack)
fname="svg_cards.svg"
dir="cards"

mkdir -p $dir

do_extract() {
  echo Extracting $1...
  inkscape -f $fname -i $1 -e $dir/$1.png
}

for s in ${suit[@]}; do
  for r in ${rank[@]}; do
    do_extract "$r"_"$s"
  done
done

do_extract black_joker
do_extract red_joker
do_extract back

Установка и простейший пример

Meteor устанавливается одной строкой (Mac и Linux):

$ curl https://install.meteor.com | /bin/sh

Для Windows пока есть только неофициальный установщик, качается он здесь.

Настройка WebStorm для Meteor

У WebStorm на данный момент нет встроенной поддержки Meteor. Но облегчить себе жизнь можно, подключив библиотеки Meteor. Для этого нужно: File -> Settings -> JavaScript -> Libraries, нажать кнопку Add, в появившемся окне нажать «+», выбрать Attach Directories и указать папку /home//.meteor/packages. Затем перезапустить WebStorm.

Для больших проектов нужно убрать папку .meteor из каталогов, входящих в проект, для того, чтобы WebStrom не переиндексировал регулярно ее содержимое. Для этого нужно в File -> Settings -> Directories выбрать папку .meteor и нажать на Excluded.

Приложение Meteor в среде разработки WebStorm
Приложение Meteor в среде разработки WebStorm

Создадим новый проект:

$ meteor create cards 

и запустим:

$ cd cards
$ meteor 
=> Meteor server running on: 
http://localhost:3000/

Результат можно сразу увидеть в браузере, открыв указанный URL.

Новый проект состоит из трех файлов: myapp.js, myapp.html, myapp.css. В этих файлах содержится простейшее приложение Meteor. Заглянем в них.

Файл cards.html включает фрагменты-заготовки для HTML, отображаемого у клиента.

<head>
  <title>clean</title>
</head>

<body>
  {{> hello}}
</body>

<template name="hello">
  <h1>Hello World!</h1>
  {{greeting}}
  <input type="button" value="Click" />
</template>

Привычных тегов здесь нет. Meteor выделит элементы и и передаст их клиенту, а шаблоны будут сконвертированы в функции JavaScript, которым потом будут переданы данные для подстановки.

Meteor использует шаблонизатор Handlebars, выражения которого выделяются фигурными скобками. Первое такое выражение {{> hello}} ссылается на объявленный ниже шаблон , который будет подставлен вместо него. Шаблон — это фрагмент обычного HTML. В данном случае он состоит из текстового заголовка, Handlebars-выражения {{greeting}} и обычной кнопки. Тот код, который будет подставлен на место выражения {{greeting}}, и функция — обработчик нажатия на кнопку определены в файле cards.js, в этом простейшем примере, состоящем из трех частей:

if (Meteor.isClient) {
  Template.hello.greeting = function () {
    return "Welcome to cards.";
  };

  Template.hello.events({
    'click input' : function () {
      // template data, if any, is available in 'this'
      if (typeof console !== 'undefined')
        console.log("You pressed the button");
    }
  });
}

if (Meteor.isServer) {
  Meteor.startup(function () {
    // code to run on server at startup
  });
}

Приложение Meteor состоит из JavaScript, выполняемого на клиентской стороне в браузере, JavaScript, выполняемого на сервере Meteor внутри контейнера Node.js (если быть точным, то внутри fiber), и вспомогательных — фрагментов HTML, CSS и статических файлов.

Сервер Meteor, запущенный в консоли
Сервер Meteor, запущенный в консоли

Все файлы, которые лежат в корне проекта, используются и клиентом, и сервером, и один и тот же код может выполняться как на стороне сервера, так и на стороне клиента. Для того чтобы определить во время выполнения, сервер это или клиент, используются флаги Meteor.isClient и Meteor.isServer. Если необходимо, чтобы файл был использован только клиентом или только сервером, его нужно поместить в подкаталог client/ или server/ соответственно. Кроме этих подкаталогов, могут быть public/, где лежат статические файлы, которые Meteor отдаст браузеру, и private/, файлы которого доступны только JS-сценариям сервера.

При старте Meteor загружает все файлы из каталогов, соответствующих тому, на какой стороне выполняется код; для клиентской стороны JavaScript минифицируется и передается одним пакетом (называемым bundle) браузерам. Все CSS также отправляются одним пакетом.

Вернемся к нашему файлу carsd.js. Template.hello.greeting = function() {}; — объявление функции, которая будет вызываться каждый раз при необходимости сформировать конечную страницу, и результат ее выполнения будет помещен вместо {{greeting}} внутри шаблона .

Template.hello.greeting = function () {
  return "Welcome to cards.";
};

Обработчик нажатия на кнопку в Meteor выглядит так:

Template.hello.events({
  'click input' : function () {
    // Тело функции
  }
});

Это значит, что в шаблоне по событию click элемента input в консоль будет выведена тестовая строка.

Пока ничего особенного, даже кажется немного запутанным? Возможно. Но подождите еще немного — все самое интересное впереди.

Игра начинается

Прежде всего научимся делать интерфейс к группе открытых карт, например тех, которые в данный момент выложены на стол.

У каждой карты есть сила (rank), масть (suit) и позиция на столе (pos). Будем считать, что карты можно раскладывать только вдоль горизонтальной линии. Тогда pos пусть будет координата карты от левой стороны элемента HTML, внутри которого она находится. Кроме того, это же значение пусть будет определять порядок, на котором карта находится внутри колоды (карта с более высоким значением pos находится выше в колоде, чем карта с меньшим), хотя, безусловно, это и не самый лучший вариант — смешивать локальные координаты внутри одного из клиентских браузеров и порядок сортировки.

Поместим изображения карт в поддиректорию public/cards. Откроем файл cards.html и удалим из него весь текст, добавив следующие строки:

<head>
  <title>cards</title>
</head>

<body>
  {{> play_tmpl}}
</body>

<template name="play_tmpl">
  <div id="play_area" class="area">
    {{#each cards}}
      <div class="card {{selected}}" draggable="true">
        <img class="cardimg" src="cards/{{rank}}_{{suit}}.png" >
      </div>
    {{/each}}
  </div>
</template>

Все тело документа состоит из одного шаблона, внутри него размещается итератор Handlebar {{#each cards}}, который при формировании HTML-документа проходит по всем элементам списка cards. Внутри него — элемент

, класс которого определяется выражением {{selected}}, а позиция — {{pos}}. В него вложен элемент , который возьмет картинку, определяемую выражениями {{rank}} и {{suit}}, то есть силой и мастью.

Совсем немного CSS — в cards.css — стол для игры в карты ведь должен быть покрыт зеленым сукном:

.area {
  padding:20px;
  background-color: green;
}

#play_area {
  position: relative;
  height: 300px;
  width: 700px;
}

Одна из уникальных сторон Meteor — при внесении изменений нет необходимости перезапускать сервер: изменения, внесенные не только в HTML и CSS, но и в JavaScript, будут отражены немедленно после сохранения файла, с использованием актуальных данных, и даже в браузере текущую страницу потребуется обновить только после фатальной ошибки.

Derby.js

Наиболее известная альтернатива Meteor — Derby.js. Эти фреймворки очень похожи по своим функциональным возможностям. В числе основных отличий — использование стандартного менеджера пакетов npm (Derby сам является модулем npm), отвязка от синтаксиса MongoDB (хотя и в Derby эта БД также используется по умолчанию), не поддерживается «живое» изменение кода, несколько меньше сообщество и выше порог вхождения.

Meteor.Collection

Ну а теперь, пожалуй, самое невероятное (впрочем, те, кто уже знаком с Derby.js — не удивятся). В состав Meteor входит MongoDB, доступная одновременно и со стороны сервера, и со стороны клиента. Изменения на стороне клиента кешируются в локальной версии базы (если быть точным, в ее эмуляторе, называемом minimongo) и отправляются на сервер. Данные с серверной стороны «публикуются» для клиентов и автоматически обновляются в их локальных версиях.

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

Для этого используется объект типа Meteor.Colleciton.

В файл cards.js, предварительно удалив существующий текст, вставим строки:

// По умолчанию все изменения данных объекта playingCards как с серверной, 
// так и с клиентской стороны будут автоматически синхронизироваться 
// между сервером и всеми его клиентами
playingCards = new Meteor.Collection("playingCards");

// Проверка, что код выполняется на клиенте
if (Meteor.isClient) {
  // Привязываем функцию к шаблону <Template name="play_tmpl"> HTML-файла
  Template.play_tmpl.cards = function() {
    // Функция возвращает результат запроса к БД MongoDB
    // Поиск всех документов, отсортированный по возрастанию поля pos
    return playingCards.find({}, {sort: ({pos:1})});
  };
}

// Проверка, что код выполняется на сервере
if (Meteor.isServer) {
  // Событие, срабатывающее при запуске сервера
  Meteor.startup(function () {
    // Очистим и добавим предопределенные карты
    playingCards.remove( {} );
    playingCards.insert( {rank: "1", suit: "spade", pos: 100, selected: false} );
    playingCards.insert( {rank: "2", suit: "heart", pos: 200, selected: true } );
    playingCards.insert( {rank: "3", suit: "diamond", pos: 300, selected: false} );
    playingCards.insert( {rank: "4", suit: "club", pos: 400, selected: false} );
  });
}

В нашем примере объявлена функция Template.play_tmpl.cards = function(), которая возвращает в клиенте результат запроса в формате базы MongoDB из коллекции playingCards. При этом при любом изменении данных внутри playingCards данные на веб-странице будут автоматически заново отрендерены.

Данные в саму эту коллекцию заносятся сервером чуть ниже по событию Meteor.startup(); Если коллекция объявлена так, как это сделано у нас сейчас, ее видимость ограничена пакетом (если бы мы разрабатывали пакет) или приложением и она доступна из консоли, например:

> playingCards.findOne();
  Object {_id: "CpuEFFzgpcs6bAmkv", rank: "1", suit: "spade", pos: 100, selected: false}

Обрати внимание на поле _id — это автоматически генерируемый уникальный идентификатор документа внутри базы данных MongoDB. Если же мы объявим ее с ключевым словом var:

var playingCards = new Meteor.Collection("playingCards");

то видимость будет ограничена только текущим файлом и такая переменная не будет доступна в консоли браузера. Взгляни на результат в браузере. Те данные, которые добавлены с серверной стороны, доступны во всех клиентах.

Верно будет и обратное. Поперекладываем карты в браузере, чтобы почувствовать все это своими руками. Для этого в файл cards.js добавим вполне типовую обработку drag and drop в браузере внутри блока if (Meteor.isClient) {} аналогично тому, как это было сделано в самом первом примере:

Template.play_tmpl.events({
  'dragstart .card' : function(e) {
    e.dataTransfer.setData("source", "play");
    e.dataTransfer.setData("_id", this._id); // Сохраняем идентификатор объекта данных
    var x = (e.offsetX==undefined) ? e.layerX : e.offsetX; // FF & Chrome support
    var y = (e.offsetY==undefined) ? e.layerY : e.offsetY;
    e.dataTransfer.setData("offsetX", x); 
    e.dataTransfer.effectAllowed='move';
    e.dataTransfer.setData("Text", e.target.getAttribute('id'));
    e.dataTransfer.setDragImage(e.target, x, y);//target.height/2);
    return true;
  },
  'drop #play_area' : function(e) {
    e.preventDefault();
    var x = (e.offsetX==undefined) ? e.layerX : e.offsetX; // FF & Chrome support
    var origX = e.dataTransfer.getData("offsetX");
    if (e.target.className.indexOf('cardimg') !== -1)      // Если под указателем — карта
      x += e.target.parentElement.offsetLeft;              // то нужно учесть ее положение относительно parent
    var id = e.dataTransfer.getData("_id");
    if (e.dataTransfer.getData("source") === ("play")) {   // Меняем позицию карты
      playingCards.update( {_id: id}, {$set: {pos: x-origX}}); // Корректируем документ в БД
    }
    e.stopPropagation();
    return false;
  },
  'dragover #play_area' : function(e) {
    e.preventDefault();
    console.log(e.dataTransfer.getData("source"));
    if (e.dataTransfer.getData("source") === ("play")) return true;
    else return false;
  },
});

Внутри обработчика события с помощью объекта this нам непосредственно доступны те данные, которые были использованы при заполнении шаблона. Так, внутри обработчика 'dragstart .card' this._id — это тот самый внутренний уникальный идентификатор документа в терминах MongoDB, а this.suit — масть карты, которую начали перетаскивать. Идентификатор _id мы и сохраняем в привычном объекте e.dataTransfer. В конце перетаскивания мы получаем сохраненный идентификатор и корректируем поле pos карты с идентификатором _id в соответствии с текущей и исходной позициями указателя мыши с помощью метода

playingCards.update( {_id: id}, {$set: {pos: x-origX}});

Все обновление данных между всеми клиентами и сервером дальше происходит автоматически. Открой второе окно браузера, поперекладывай карты.

Только задумайся: в нашем проекте нет и ста строк кода, из которых чуть ли не большая часть отводится на отработку перетаскивания, а представление данных между всеми подключенными клиентами и сервером уже синхронизировано.

Для компенсации задержек при обмене с сервером все изменения данных Meteor.Collection каждого клиента сначала сохраняются в локальном хранилище и отображаются пользователю. Далее они передаются серверу, который сохраняет новые значения уже в своей базе и отправляет обратно подтверждение, затем рассылает их всем остальным клиентам, а они автоматически отобразят обновленные данные при их поступлении. В случае если сервер отказал в изменениях, клиент откатывается к предыдущему состоянию.

Meteor.Session

В HTML-файле еще остается выражение {{selected}}, которое мы пока не реализовали. С его помощью сделаем выбор карты для какой-либо дальнейшей операции.

Использованный нами объект Session предназначен для хранения пар ключ:данные, действительных в рамках одной сессии.

В cards.js в шаблон Template.play_tmpl.events({}) добавим (отделив запятой от предыдущих):

'click .card' : function () {
  Session.set("selPlaying", Session.equals("selPlaying", this._id) ? null : this._id );
},

а внутри блока if (Meteor.isClient) {} — функцию, которая будет формировать значение для шаблона

Template.play_tmpl.selected = function () {
  return Session.equals("selPlaying", this._id) ? "selected" : '';
};

Для визуального выделения выбранной карты добавим в .css:

.card {
  padding: 3px;
}

.selected {
  outline:lime solid 5px;
}

Вызов серверного метода со стороны клиента

Мы уже научились работать с Collection и Session. Чтобы сделать ход, нам необходимо вызвать серверную функцию — не можем же мы непосредственно на стороне клиента реализовывать перекладывание карты между, скажем, оставшейся колодой и картами игрока, ведь в этом случае небезопасный браузер получает полный контроль над игровой ситуацией.

В нашем пробном приложении реализуем добавление и удаление сервером карт игрока по двойному клику на игровом поле или карте соответственно.

Методы сервера, доступные для вызова со стороны клиента, объявляются методом Meteor.methods({}). Со стороны сервера (блок if (Meteor.isServer) {}) добавим:

Meteor.methods({
  addCard: function (coordX) {
    if (!coordX) {
      // Если координата не указана, вставим правее самой крайней
      var c = playingCards.findOne({}, {sort: {pos:-1}}); // Карта с максимальной pos (крайняя правая)
      coordX = ( c ) ? c.pos + 50 : 0;
    }
    var ranks = ["6", "7", "8", "9", "10", "jack", "queen", "king", "1"];
    var suits = ["spade", "heart", "diamond", "club"];
    var r = Math.floor(Math.random() * ranks.length );
    var s = Math.floor(Math.random() * suits.length );
    console.log("addCard:", {rank: r, suit: s, pos: coordX, selected: false });
    playingCards.insert( { rank: ranks[r], suit: suits[s], pos: coordX, selected: false } );
  },
  delCard: function (id) {
    console.log("delCard: id: ", id });
    playingCards.remove( {_id: id } );
  }
});

Сам вызов серверного метода клиентом осуществляется методом Meteor.call(). У клиента в события шаблона добавим (отделив запятой от предыдущих):

'dblclick .card' : function(e) {
  Meteor.call("delCard", this._id);
  e.stopPropagation();
},
'dblclick #play_area' : function(e) {
  Meteor.call("addCard");
  e.stopPropagation();
}

Безопасность

По умолчанию новое приложение Meteor включает в себя пакеты autopublish и insecure, которые дают клиенту полный доступ к базе на чтение и запись, и все данные рассылаются всем клиентам. Это предназначено для упрощения прототипирования, и именно это позволяет нам видеть наши данные напрямую с обеих сторон. Но в реальной практике использовать такую конфигурацию нельзя, ведь клиент может, скажем, вытащить туза из рукава (в консоли):

playingCards.insert({rank: "1", suit: "heart", pos: 500})

В «рабочем» варианте данные должны явно публиковаться сервером. Для этого нужно добавить следующие строки в блок if (Meteor.isServer() {}):

Meteor.publish("rooms", function () {
  return playingCards.find({}, {fields: {rank: 1, suit:1, pos: 1}});
});

В данном случае мы возвращаем поля rank, suit, pos всех имеющихся документов.

Сразу после добавления такого кода Meteor напишет в серверной консоли:

You’ve set up some data subscriptions with Meteor.publish(), but
you still have autopublish turned on <..>

Если мы сейчас удалим этот пакет, клиент перестанет получать данные:

$ meteor remove autopublish

Теперь клиент должен быть явно подписан на данные, опубликованные сервером. Добавим первой строкой в блоке if (Meteor.isClient) {}

Meteor.subscribe("playingCards");:

После этого нормальный обмен данными возобновится и будет вестись только с теми клиентами, которые на эти данные подписаны.

После удаления пакета insecure:

$ meteor remove insecure

на любую попытку обновления данных (перемещение карт) клиент будет получать ошибку:

update failed: Access denied

Доступ можно ограничивать произвольным образом. Например, в нашем случае клиент имеет право только изменять значение единственного поля pos карты, и больше ничего. Реализуется такое ограничение следующим образом:

playingCards.allow({
  insert: function (userId, doc) { // добавление новых записей
    return false;
  },
  update: function (userId, doc, fields, modifier) { // изменение записей
    return _.contains(fields, "pos"); // разрешено только 
  },
  remove: function (userId, doc) { // удаление записей
    return false;                  // запрещено
  },
});

playingCards.deny({
});

Аутентификация пользователей

Сколько нужно строк для того, чтобы включить в веб-приложение поддержку авторизации Facebook, GitHub, Google, Twitter?.. В Meteor для этого достаточно двух строк — в него входят готовые пакеты с поддержкой всех этих платформ плюс собственная система аутентификации с поддержкой регистрации пользователей и даже подтверждением по почте и функцией восстановления пароля.

Останови Meteor и введи:

meteor add accounts-ui accounts-google

Добавь в .html:

{{loginButtons align="right"}}<br>

И запусти снова.

Для красоты добавим CSS:

html {
  padding: 10px;
  font-family: Verdana, sans-serif;
}

.login-buttons-dropdown-align-right {
  float: right;
}

Чтобы авторизация через Google заработала, в Google необходимо сконфигурировать новое веб-приложение. Но для того, чтобы облегчить даже это, подробная пошаговая инструкция будет отображена при нажатии на кнопку Configure Google Logon.

Текущий зарегистрированный пользователь доступен в клиенте с помощью функций Meteor.user() и Meteor.userId(). Чтобы получить, например, URL изображения пользователя, введи в консоли браузера:

> Meteor.user().profile.name

Чтобы включить поддержку остальных механизмов авторизации:

meteor add accounts-password, accounts-facebook, accounts-github, accounts-twitter, or accounts-weibo.

Все зарегистрированные на данный момент пользователи доступны в коллекции Meteor.Users. С неавторизованными пользователями дело сложнее, так как Meteor не ведет их учет напрямую. Если их список все же необходим, можно использовать способ, примененный в «Как отслеживать число анонимных пользователей на серверной стороне в Meteor».

Развертывание

А хочешь развернуть свое тестовое приложение в облаке? Одна строчка:

$ meteor deploy <имя приложения>.meteor.com

Ребята из Meteor предоставляют бесплатный сервис, с помощью которого можно мгновенно открыть публичный тестовый доступ к своему приложению. При этом можно использовать и свой домен. При этом опцией --password можно указать пароль (без пробела), который нужно будет вводить при дальнейших административных действиях с сайтом.

Пример из этой статьи можно увидеть здесь.

При развертывании на собственной инфраструктуре команда

$ meteor bundle cards.tgz

подготовит полный пакет приложения cards.tgz (к нему дополнительно потребуются Node.js и MongoDB). Подробнее см. документацию тут и тут.

Дополнительные пакеты

Пакеты Meteor могут использовать стандартные модули npm. Но у Meteor есть и собственная экосистема дополнительных модулей, smart packages, называемая Atmosphere, она содержит на данный момент более 700 модулей.

Для работы с ней потребуется утилита Meteorite:

$ npm install -g meteorite

После этого команда

$ mrt add <название пакета>

загрузит последнюю версию указанного пакета из Atmosphere.

Другие особенности

Отладка приложений Meteor непроста. Несколько советов о том, как это лучше сделать, можно посмотреть здесь. Генерация HTML-кода на стороне клиента, что вызывает сложности при индексировании страниц (решается использованием пакета spiderable).

Meteor сильно привязан к MongoDB (хотя доступ к другим типам баз реализуется через пакеты, синтаксис при обращении к ним все равно будет Mongo).

Еще одной сложностью является реализация анимации, так как Meteor сам занимается рендерингом страницы.

Внешний вид приложения, открытого в браузере
Внешний вид приложения, открытого в браузере

Заключение

Если Meteor тебя заинтересовал, то есть смысл взглянуть на предустановленные примеры, очень простые, но показывающие, как динамично может выглядеть пользовательский интерфейс под Meteor, здесь. Актуальной документацией можно разжиться здесь.

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    6 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии