Meteor. Разрабатываем TODO List

  • Tutorial
В данном уроке я не хочу обсуждать, почему метеор убийца веба, тем более я так не считаю, но определенную симпатию к этому фреймворку имею. Поэтому хочу показать с чего можно начать при разработке приложения на нем, какие есть пакеты и вообще, что такое этот метеор.

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

Еще хочу предупредить, что урок получился достаточно объемным, но кода в нем было написано в разы меньше, чем текста. Просто хочу поделиться опытом как можно использовать метеор не при создании простенького примера, и заострить внимание на различных моментах, которые я посчитал важными. Поэтому в уроке будет использоваться множество сторонних пакетов, облегчающих процесс разработки.

И еще одно предупреждение: в данном уроке будут использоваться следующие технологии для непосредственного написания примера:

  • jade — html препроцессор;
  • less — css препроцессор;
  • coffeescript — язык программирования, компилируемый в javascript.

Видео, демонстрирующее приложение, полученное в ходе урока



И кому все еще интересно, добро пожаловать под кат.


Установка Meteor

Сам метеор базируется на nodejs и mongodb, так же у метеора нет поддержки Windows, и если вы собрались пощупать метеор вам необходимо обзавестись операционной системой Linux или MacOS.

Первым делом устанавливаем nodejs и mongodb.

Следующим шагом необходимо установить метеор. Он не лежит в npm репозитории, поэтому не нужно торопиться и командовать npm install -g meteor, в данном случае лишь загрузиться его старая версия, для правильной установки необходимо выполнить в консоли:

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


Создание проекта

После установки метеора можно сходу командовать

$ meteor create 'todo-list'
todo-list: created.

To run your new app:
   cd todo-list
   meteor
$ cd todo-list
$ meteor
[[[[[ ~/dev/meteor-getting-started/todo-list ]]]]]

=> Started proxy.
=> Started MongoDB.
=> Started your app.

=> App running at: http://localhost:3000/


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

helloworld

Теперь, после проверки работоспособности нового проекта, файлы, находящиеся в корне проекта, можно удалить — нам они не особо интересны. Также можно заметить, что была создана директория .meteor, там хранится различная служебная информация о проекте и даже автоматически сгенерированный .gitignore. Кстати для ручного управления пакетами можно изменять файл packages, но консольные утилиты тоже достаточно удобны.

Если у вас такой же результат как и у меня, значит минимальное окружение для разработки метеор проекта готово, если же что-то пошло не так — проверьте корректность установки nodejs, mongodb и meteor, например, у меня на компьютере сейчас следующая конфигурация:

$ node -v
v0.10.33
$ mongod --version
db version v2.4.12
$ meteor --version
Meteor 1.0


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

Пакеты

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

Установка пакетов производится командой

$ meteor add <package-name>


Как я писал выше, приложение будем разрабатывать на less, jade и coffeescript, а значит самое время установить их. Все пакеты, используемые в уроке, и кучу других можно найти на сайте Atmosphere. Собственно названия пакетов:

  • less, coffeescript — это официальные пакеты, поэтому не содержат имя автора;
  • mquandalle:jade — а вот это не официальный пакет, поэтому название состоит из двух составляющих, но выполнен он хорошо, и никаких проблем при его использовании у меня не возникало.

В пакеты less и coffeescript встроена поддержка sourcemap, поэтому и процесс дебага в браузере будет простым, sourcemap поддерживается самим метеором: он предоставляет необходимый api для подключения данного функционала, поэтому нам не придется, что-то специально настраивать.

По ходу разработки мы добавим еще несколько популярных пакетов, и я постараюсь описать назначения каждого. Кстати jquery и underscore уже включены в метеор, как и множество других пакетов, полный список можно посмотреть в файле ./.meteor/versions, в созданном проекте.

Структура приложения

Теперь, по-моему мнению, самое время разобраться с тем как метеор подключает файлы в проект, и какие способы регуляции этого существуют. Здесь нам не потребуется писать конфигурационные файлы для grant или gulp, что бы скомпилировать шаблоны, стили и скрипты, метеор уже позаботился об этом. Для скафолдинга существует проект для Yeoman, но мне приятнее все создавать в ручную. В предыдущем проекте я использовал примерно следующую структуру папок:

todo-list/           - корень проекта
├── client           - тут будут чисто клиентские файлы
│   ├── components   - компоненты приложения будут состоять из шаблона 
│   │                  и скрипта, реализующего его логику
│   ├── config       - файлы конфигурации
│   ├── layouts      - базовые шаблоны, не имеющие никакой логики
│   ├── lib          - различные скрипты, которые могут понадобится на
│   │                  клиенте
│   ├── routes       - клиентский роутинг
│   └── styles       - стили
├── collections      - здесь будем хранить модели
├── lib              - скрипты, которые могут понадобится везде
├── public           - статика: картинки, robots.txt и все такое
├── server           - файлы для серверной части приложения
│   ├── methods      - тут будут серверные методы, типа реста,
│   │                  только удобнее
│   ├── publications - расшаривание данных из коллекций
│   ├── routes       - серверный роутинг, собственно можно будет
│   │                  контролировать http запросы
│   └── startup      - инициализация сервера


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

  • все файлы из папки public в корне проекта будут доступны пользователям по ссылки из браузера и не будут автоматически подключаться к проекту;
  • все файлы из папки server в корне, доступны только серверной части приложения;
  • все файлы из папки client в корне, доступны только клиентской части приложения;
  • все остальное, что находится в корне, доступно в любой среде;
  • файлы в проект подключаются автоматически, по следующим правилам:
  • загрузка начинается с поддиректорий, и первой всегда обрабатывается директория с именем lib, далее все* папки и файлы загружаются в алфавитном порядке;
  • файлы начинающиеся с main. загружаются последними.

Например рассмотрим, как будет загружаться наш проект в браузере: первым делом будут загружены файлы из директории lib в корне проекта, далее будет обрабатываться папка client, в ней также первым делом загрузятся файлы из lib, а потом в алфавитном порядке: components -> config ->… -> styles. И уже после файлы из папки collections. Файлы из папок public и server, не будут загружены в браузер, но, например, скрипты, хранящиеся в папке public, можно подключить через тег script, как это мы привыкли делать в других проектах, однако разработчики фреймворка не рекомендуют подобный подход.

Также для регуляции среды выполнения в общих файлах можно воспользоваться следующими конструкциями:

if Meteor.isClient
  # Код, выполняемый только в браузере
if Meteor.isServer
  # Код, выполняемый только на сервере


И для регулирования времени выполнения скриптов мы можем использовать метод Meteor.startup(<func>), в браузере это аналог функции $ из библиотеки jQuery, а на сервере, код в данной функции выполнится сразу же после загрузки всех скриптов в порядке загрузки этих файлов. Подробнее об этих переменных и методах.

Базовый шаблон приложения

Для верстки я буду использовать Bootstrap, да знаю, что он всем приелся, но верстальщик из меня никакой, а с бутстрапом я более менее знаком.

Для этого устанавливаем пакет mizzao:bootstrap-3 — он самый популярный среди прочих, и думаю при его использовании у нас не должно возникнуть проблем.

Далее создаем в папке client/layouts файл head.jade. Это будет единственный файл в нашем приложении не имеющий формат шаблона, короче просто создадим шапку страницы, а позже разберем что такое шаблоны.

//- client/layouts/head.jade
head
  meta(charset='utf-8')
  meta(name='viewport', content='width=device-width, initial-scale=1')
  meta(name='description', content='')
  meta(name='author', content='')

  title Meteor. TODO List.


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

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

После установки в директории client/config создаем файл router.coffee, со следующим содержанием:

# client/config/router.coffee
Router.configure
  layoutTemplate: "application"


Очевидно, что здесь мы задаем базовый шаблон для нашего приложения, он будет называться application. Поэтому в папке layouts создаем файл application.jade. В этом файле мы опишем шаблон, некоторую сущность, которая на этапе сборки приложения превратится в код на javascript. Кстати в метеоре используется их собственный усатый шаблонизатор spacebars и библиотека blaze.

Если коротко, то процесс работы шаблонов выглядит следующим образом (на сколько я понял из документации). Шаблоны spacebars компилируются в объект библиотеки blaze, которая в последствии будет работать непосредственно с DOM. В описании к проекту есть сравнение с другими популярными библиотеками:

  • по сравнению с обычными текстовыми шаблонизаторами блейз работает с домом, он не будет заново рендерить весь шаблон, что бы поменять один аттрибут, и у него нет проблем с вложенными шаблонами;
  • по сравнению с шаблонами Ember, блейз рендерит только изменения, нет нужды в явных дата-байдингах и в описаниях зависимостей между шаблонами;
  • по сравнению с шаблонами angular и polymer, блейз имеет понятный и простой синтаксис, меньший порог входа и вообще не позиционируется как технология будущего, а просто работает;
  • по сравнению с React имеет простой синтаксис описания шаблонов и простое управление данными.

Это я практически перевел параграф из официального описания библиотеки, так что прошу не кидаться в меня камнями, если с чем-то не согласны. Сам я сталкивался с этими технологиями (кроме ember) и в принципе согласен с авторами библиотеки, из минусов в блейзе хочу заметить завязку на метеоре.

Но мы в своем проекте не используем явно ни blaze, ни spacebars. Для jade шаблонов процесс компиляции имеет такую последовательность: jade -> spacebars -> blaze.

Все шаблоны в метеор описываются в теге template, где должен быть аттрибут с именем шаблона. Помните, мы в настройках роутера указали layoutTemplate: "application", вот application, как раз и является именем шаблона.

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

//- client/layouts/application.jade
template(name='application')
  nav.navbar.navbar-default.navbar-fixed-top
    .container
      .navbar-header
        button.navbar-toggle.collapsed(
          type='button',
          data-toggle='collapse',
          data-target='#navbar',
          aria-expanded='false',
          aria-controls='navbar'
        )
          span.sr-only Toggle navigation
          span.icon-bar
          span.icon-bar
          span.icon-bar
        a.navbar-brand(href='#') TODO List
      #navbar.collapse.navbar-collapse
        ul.nav.navbar-nav

  .container
    +yield

  .footer
    .container
      p.text-muted TODO List, 2014.


Здесь нужно понимать, что это не совсем привычный нам jade, с его миксинами, джаваскриптом и инклудами. Jade должен скомпилироваться в шаблон spacebars, и это накладывает некоторые особенности. От jade, можно сказать мы заберем только синтаксис, остальное нам просто не нужно. В данном шаблоне используется конструкция +yield, это конструкция означает, что вместо нее будет отрендерен шаблон yield, это особенность iron:router, он автоматически подставит нужный шаблон в зависимости от пути, чуть позже мы займемся роутерами, а сейчас можно внести косметические изменения в верстку и посмотреть на результат.

// client/styles/main.less
html {
  position: relative;
  min-height: 100%;
}

body {
  margin-bottom: 60px;

  & > .container{
    padding: 60px 15px 0;
  }
}

.footer {
  position: absolute;
  bottom: 0;
  width: 100%;
  height: 60px;
  background-color: #f5f5f5;

  .container .text-muted {
    margin: 20px 0;
  }
}


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

my_helloworld

Роутинг

В самом метеоре нет стандартного механизма роутинга, я предлагаю использовать пакет iron:router, он хорошо документирован, активно поддерживается, обладает богатым функционалом и также является самым популярным решениям для роутинга в контексте метеора.

Еще эту библиотеку можно использовать для серверного роутинга. Например, мне, на реальном проекте, это понадобилось для авторизации пользователей, так как основной проект сделан на Ruby on Rails, а пользователям нет нужды думать, что это два различных приложения и проходить в них авторизацию дважды. Вообще для серверного роутинга и создания REST api для метеора есть несколько популярных подходов.

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

Для начала зададим ссылки на наши страницы

//- client/layouts/application.jade
//- ...
#navbar.collapse.navbar-collapse
  ul.nav.navbar-nav
    li
      a(href='/') Home
    li
      a(href='/about') About


Создаем контроллеры в папке клиентских роутеров, пока это будут просто заглушки

# client/routes/home.coffee
Router.route '/', name: 'home'
class @HomeController extends RouteController

  action: ->
    console.log 'Home Controller'
    super()

# client/routes/about.coffee
Router.route '/about', name: 'about'
class @AboutController extends RouteController

  action: ->
    console.log 'About Controller'
    super()


В функцию Router.route нужно передать два параметра, первый это путь, причем путь может быть паттерном (например: /:user/orders/:id/info), все параметры из паттерна будут доступны в объекте контроллера, через свойство params. Вторым параметром передается объект с опциями. Чтобы вынести всю логику отдельно от простого описания пути и имени, можно создать контроллеры, в нашем случае это простые заглушки, здесь в свойствах мы не указываем явно имена контроллеров, потому что по умолчанию iron:router пытается найти контроллер с именем <RouteName>Controller, и конечно, наши контроллеры должны быть доступны глобально, в кофескрипте мы это делаем, привязывая переменную к текущему контексту, в обычном js, достаточно просто объявить переменную не через var.

К слову, в метеоре не используется, например amd для загрузки кода, файлы просто загружаются в определенной последовательности. Поэтому все взаимодействие между модулями, описанными в разных файлах, осуществляется через глобальные переменные. Что, как по мне, достаточно удобно, а при использовании кофе, случайно объявить глобальную переменную достаточно сложно, и она сразу будет заметна.

iron:router также попытается автоматически отрендерить шаблон, с именем роута (но шаблоны можно указать и явно), создадим их

//- client/components/home/home.jade
template(name='home')
  h1 Home

//- client/components/about/about.jade
template(name='about')
  h1 About


Можно открыть браузер и убедиться, что наш роутинг работает, покликав на ссылки в шапке. Причем работает без обновления страницы.

base_routing

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

Здесь можно посмотреть, что получилось в итоге, а здесь посмотреть код проекта в текущем состоянии.

Пользователи и аутентификация

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

Мы не будем сильно углубляться в механизмы, а просто используем готовые решения, которые позволят нам создавать пользователей через логин/пароль или сервисы google и github. Я привык в рельсах настраивать связку devise и omniauth парой генераторов и несколькими строчками в конфиге. Так вот метеор мало того, что предоставляет это из коробки, так еще и настройка сервисов происходит максимально просто.

Установим следующие пакеты:

  • accounts-base — базовый пакет для пользователей приложения на метеоре;
  • accounts-password, accounts-github, accounts-google — добавим поддержку для аутентификации через логин/пароль и сервисы github и google;
  • ian:accounts-ui-bootstrap-3 — пакет для упрощения интеграции аккаунтов в приложение на бутстрапе.

Пакет ian:accounts-ui-bootstrap-3 нам позволит одной строчкой добавить форму аутентификации/регистрации в приложение, а также предоставит интерфейс к настройке сторонних сервисов. Сам проект, там есть небольшая документация и скриншоты того как выглядят интеграция формы и настройка сервисов.

Модифицируем нашу шапку

//- client/layouts/application.jade
//- ...
#navbar.collapse.navbar-collapse
  ul.nav.navbar-nav
    li
      a(href='/') Home
    li
      a(href='/about') About
  ul.nav.navbar-nav.navbar-right
    //- шаблон кнопки авторизации пользователя
    //- идет в пакете ian:accounts-ui-bootstrap-3
    +loginButtons


И получим следующий результат

base_auth_form


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

$ meteor mongo
MongoDB shell version: 2.4.9
connecting to: 127.0.0.1:3001/meteor
meteor:PRIMARY> show collections
meteor_accounts_loginServiceConfiguration
meteor_oauth_pendingCredentials
system.indexes
users
meteor:PRIMARY> db.meteor_accounts_loginServiceConfiguration.find()
{
  "service" : "github",
  "clientId" : "<id>",
  "secret" : "<secret>",
  "_id" : "AjKrfCXAioLs7aBTN"
}
{
  "service" : "google",
  "clientId" : "<id>",
  "secret" : "<secret>",
  "_id" : "HaERjHLYmAAhehskY"
}


Сконфигурируем нашу систему пользователей, так как я хочу настроить верификацию адреса электронной почты, необходимо настроить smtp, кстати для отправки email используется пакет email. Он не входит в стандартный набор метеора, поэтому его необходимо установить вручную, если вам нужна работа с почтой.

# server/config/smtp/coffee
smtp =
  username: "meteor-todo-list@yandex.ru"
  password: "meteor-todo-list1234"
  server:   "smtp.yandex.ru"
  port:     "587"

# Экранируем символы
_(smtp).each (value, key) -> smtp[key] = encodeURIComponent(value)

# Шаблон url доступа к smtp
url = "smtp://#{smtp.username}:#{smtp.password}@#{smtp.server}:#{smtp.port}"

# Задаем переменную окружения, метеор будет использовать данные из нее
process.env.MAIL_URL = url


И сконфигурируем аккаунты, что бы метеор запрашивал подтверждение адреса электронной почты.

# server/config/accounts.coffee
emailTemplates =
  from: 'TODO List <meteor-todo-list@yandex.ru>'
  siteName: 'Meteor. TODO List.'

# заменяем стандартные настройки для почты
_.deepExtend Accounts.emailTemplates, emailTemplates

# включаем верификацию
Accounts.config
  sendVerificationEmail: true

# добавляем кастомную логику при регистрации пользователей
Accounts.onCreateUser (options = {}, user) ->
  u = UsersCollection._transform(user)
  options.profile ||= {}
  # сохраняем хеш адреса, чтобы можно было получит аватар для пользователя
  # у которого не указан публичный адрес почты
  options.profile.emailHash = Gravatar.hash(u.getEmail() || "")
  # запоминаем сервис, через который пользователь зарегистрировался
  options.service = _(user.services).keys()[0] if user.services
  # сохраняем дополнительные параметры и возвращаем объект,
  # который запишется в бд
  _.extend user, options


В нашем приложении не будет возможности подключать несколько сервисов к одному аккаунту, так как это требует тонкой настройки. Возможно скоро в метеоре проработают данный момент, но пока существует готовое, более менее нормальное, решение mondora:connect-with, но оно еще сырое. Можно попытаться самим мержить аккаунты, в этом нет ничего сложного, и в сети есть множество примеров и других решений: раз, два, три.

Также по аккаунтам есть подробная документация, мы всего лишь поставили пакеты и видим магию, но под капотом это происходит не многим сложнее.

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

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

Коллекции, публикации и подписки.

При создании проекта, автоматически были добавлены два пакета autopublish и insecure, так вот сейчас самое время от них избавиться, так как они предоставляет пользователю безграничный доступ ко всем коллекциям в бд, и их можно использовать только для прототипирования. Удаляются пакеты командой

$ meteor remove <package-name>


Коллекции


Коллекции в метеоре можно сравнить с коллекциями в монге, собственно они с ними же и работают, и у них также есть методы find, insert, update, upsert (агрегацию можно организовать на сервере при помощи пакета zvictor:mongodb-server-aggregation). Одна из коллекций у нас уже создана и доступ к ней можно получить через Meteor.users, например, попробуйте выполнить в консоли браузера Meteor.users.findOne(). Здесь важно отметить, что все данные коллекций кешируются в браузере, и если выполнить миллион раз в цикле на клиенте Meteor.users.find(options).fetch(), то ничего кроме браузера вы не нагрузите. Это достигается при помощи библиотеки minimongo, которая достаточно умная, что бы делать выборку в зависимости от переданных параметров на клиенте.

С голыми данными не очень приятно работать, хотелось бы добавить бизнес-логику в объекты коллекции, это можно сделать при помощи функции _transform у коллекции, в которую передаются объекты после получения их с сервера и там их уже можно обработать, однако чтобы не вникать в эти тонкости, можно воспользоваться пакетом dburles:collection-helpers, который к коллекции добавляет метод helpers, куда можно передать объект, от которого будут наследоваться все данные.

Установим пакет, и напишем методы для обновления данных о пользователе. Также при создании пользователя мы добавили вычисляемое поле с хешем аватара пользователя в сервисе Gravatar — добавим метод который сможет возвращать ссылку на изображение с некоторыми параметрами. Еще добавим методы для проверки сервиса регистрации пользователя и методы возвращающие различную публичную информацию.

# collections/users.coffee
Users = Meteor.users

# статические методы и свойства
_.extend Users,
  # список полей доступных для редактирования
  allowFieldsForUpdate: ['profile', 'username']

# Добавляем методы и свойства в модель
Users.helpers
  # метод обновления пользователя, можно вызывать прямо на клиенте
  update: (data) ->
    Users.update @_id, data

  # метод для обновления, который будет только устанавливать данные
  # сразу позаботимся о запрещенных полях
  set: (data) ->
    d = {}
    f = _(Users.allowFieldsForUpdate)
    for key, value of data when f.include(key)
      d[key] = value
    @update $set: d

  # метод мержить текущие данные с переданными,
  # чтобы потом их можно было использовать для обновления
  # и нечего не потерять
  merge: (data) ->
    current = @get()
    @set _.deepExtend(current, data)

  # получение только данных модели, все методы и свойства,
  # указанные здесь находятся в прототипе
  get: ->
    r = {}
    r[key] = @[key] for key in _(@).keys()
    r

  # список все адресов почты
  getEmails: ->
    p = [@profile?.email]
    s = _(@services).map (value, key) -> value?.email
    e = _(@emails).map (value, key) -> value?.address
    _.compact p.concat(e, s)

  # основной адрес
  getEmail: ->
    @getEmails()[0]

  # публичная информация
  getUsername    : -> @username || @_id
  getName        : -> @profile?.name || "Anonymous"
  getPublicEmail : -> @profile?.email

  urlData: ->
    id: @getUsername()

  # вычисляем ссылку на граватар, на основе адреса почты
  # или хеша автоматически вычисленного при регистрации
  getAvatar: (size) ->
    size = Number(size) || 200
    options =
      s: size
      d: 'identicon'
      r: 'g'
    hash = "00000000000000000000000000000000"
    if email = @getPublicEmail()
      hash = Gravatar.hash(email)
    else
      hash = @profile?.emailHash || hash
    Gravatar.imageUrl hash, options

  # проверка сервиса используемого при регистрации
  isFromGithub:   -> @service == 'github'
  isFromGoogle:   -> @service == 'google'
  isFromPassword: -> @service == 'password'

  # текущий пользователь может редактировать
  # некоторые данные о себе
  isEditable: -> @_id == Meteor.userId()

# Экспорт коллекции
@UsersCollection = Users


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

Публикации


Я создал три записи пользователя в бд

$ meteor mongo
meteor:PRIMARY> db.users.count()
3


А когда пытаюсь получить данные в браузере, то не нашел ни одной записи без аутентификации и одну (собственную) в противном случае.

fail_publish

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

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

Опубликуем коллекцию пользователей.

# server/publications/users.coffee
Meteor.publish 'users', (limit = 20) ->
  UsersCollection.find {},
    fields:
      service: 1
      username: 1
      profile: 1
    limit: limit


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

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

# server/publications/profile.coffee
Meteor.publish 'profile', ->
  # проверям авторизован ли пользователь,
  # запрашивающий подписку
  if @userId
    # подписываем на его запись в бд
    UsersCollection.find { _id: @userId },
      fields:
        service: 1
        username: 1
        profile: 1
        emails: 1
  else
    # просто говорим, что все готово
    @ready()
    return


Второй параметр передаваемый в метод Meteor.publish, это функция, которая должна вернуть курсор коллекции. Эта функция может принимать любое количество аргументов и она выполняется в контексте объекта, в котором доступны некоторые методы, позволяющие оповещать пользователя о различных изменениях в данных и предоставляющих доступ к некоторым свойствам подключения. Например, в публикации профиля мы используем метод ready, в случае когда пользователь не авторизован, это означает, что данные в публикации готовы, и на стороне клиента вызовется коллбек при подписке, но никаких данных он не получит. Подробнее о публикациях.

Подписки


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

Мы для подписок будем использовать iron:router, и он будет контролировать весь необходимый процесс, так как для ручного управления за этим процессом придется следить за многим, а данная библиотека решает все проблемы. Некоторые данные желательно выдавать постранично, поэтому прежде чем создать контроллер для пользователей, мы немного абстрагируемся и создадим класс, обладающий функционалом для управления страницами и который будет наследоваться от контроллера библиотеки iron:router.

# client/lib/0.pageable_route_controller.coffee
varName = (inst, name = null) ->
  name = name && "_#{name}" || ""
  "#{inst.constructor.name}#{name}_limit"

class @PagableRouteController extends RouteController

  pageable: true # будем проверять, что это за контроллер
  perPage: 20    # количество данных на одной странице

  # количество загружаемых данных
  limit: (name = null) ->
    Session.get(varName(@, name)) || @perPage

  # следующая страница
  incLimit: (name = null, inc = null) ->
    inc ||= @perPage
    Session.set varName(@, name), (@limit(name) + inc)

  # сборс количества
  resetLimit: (name = null) ->
    Session.set varName(@, name), null

  # все ли данные были загруженны?
  loaded: (name = null) ->
    true


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

//- client/components/next_page_button/next_page_button.jade
template(name='nextPageButton')
  unless loaded
    a.btn.btn-primary.btn-lg.NextPageButton(href = '#')
      | More


# client/components/next_page_button/next_page_button.coffee
Template.nextPageButton.helpers
  loaded: ->
    ctrl = Router.current()
    if ctrl.pageable
      ctrl.loaded(@name)
    else
      true

Template.nextPageButton.events
  'click .NextPageButton': (event) ->
    ctrl = Router.current()
    if ctrl.pageable
      ctrl.incLimit(@name, @perPage)


Здесь мы для компонента уже задаем некоторую логику. Как можно заметить все шаблоны складываются в глобальное пространство имен Template. Обратится к шаблону мы можем через Template.<template-name>. Для описания методов используемых в шаблоне нужно использовать метод helpers, куда передается объект с методами. В данном примере мы описываем лишь один метод loaded, который проверяет, что из себя представляет текущий контроллер и отдает результат, показывающий все ли данные были загружены. В самом шаблоне мы дергаем этот метод в конструкции unless loaded, также в шаблоне можно забирать данные из текущего контекста. Хелперы шаблона можно сравнить с прототипом объекта, при использовании их в шаблоне, но внутри самой функции есть ограничения, так как каждый хелпер вызывается примерно так <helper-func>.apply(context, arguments), то есть у нас нет возможности обратится ко всем хелперам шаблона, внутри функции, что в общем-то иногда может мешать.

Для обработки событий шаблона, нужно их описать в методе events, куда передается объект, с ключами следующего формата <event> <selector>. В обработчик передается jQuery событие и шаблон, в котором было вызвано событие, так как мы можем обрабатывать дочерние события в родительском шаблоне, это иногда может оказаться полезным.

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

# client/routes/users.coffee
Router.route '/users', name: 'users'
class @UsersController extends PagableRouteController

  # количество пользователей на одной странице
  perPage: 20

  # подписываемся на коллекцию пользователей, с заданными лимитом,
  # чтобы не получать лишние данные
  # 
  # подписка происходит через данный метод, чтобы iron:router
  # не рендерил шаблон загрузки страницы, каждый раз при обновлении
  # подписки
  subscriptions: ->
    @subscribe 'users', @limit()

  # возвращаем всех пользователей из локальной коллекции
  data: ->
    users: UsersCollection.find()

  # все ли пользователи загружены?
  loaded: ->
    @limit() > UsersCollection.find().count()

  # сбрасываем каждый раз лимит, при загрузки страницы
  onRun: ->
    @resetLimit()
    @next()


В методе subscriptions происходит подписка к публикации users. Есть еще практически аналогичный метод waitOn, только в нем роутер будет ожидать пока все данные выгрузятся, а после отрендерит страницу, до этого момента он будет отображать шаблон, который можно задать через свойство loadingTemplate. Данные, возвращаемые методом data, будут привязаны к шаблону, и мы сможем их использовать через текущий контекст. UsersCollection.find() возвращает курсор, а не сами данные, но блейз будет все превращения делать за нас, как-будто мы работаем уже с готовыми данными. Так как мы подписываемся на ограниченное количество данных, вызов UsersCollection.find().fetch() вернет нам лишь данные, загруженные на клиент, то есть если мы, например, установим лимит 1, то и find будет работать только с загруженной выборкой (одной записью), а не всеми данными в коллекции из базы. К примеру здесь мы переопределяем метод loaded, думаю суть его ясна, но следует помнить, что count будет возвращать нам количество локальных записей, а значит будет равен limit, пока все данные не будут выгружены, поэтому и условие строго больше.

В iron:router есть несколько хуков, например, нам было бы не плохо каждый раз при открытии страницы с пользователями сбрасывать лимит загруженных. Иначе если мы ранее выгрузили большой объем данных, то страница может долго рендериться. Поэтому для сброса лимита данных удобно использовать хук onRun. Выполняется он один раз, при загрузке страницы. Есть только момент, что при горячей замене кода, которую выполняет метеор, после сохранения файлов с кодом, этот хук выполнятся не будет, так что вручную обновляйте страницу при дебаге контроллера, использующего этот хук (с другими такой проблемы нет). Еще про хуки и подписки.

Реактивные переменные и функции


Вот мы подписались на публикацию, но все равно может быть не понятно почему клики по кнопке из шаблона nextPageButton, будет приводить нас к загрузке новой порции данных, а все благодаря манипуляциям с объектом Session в PagableRouteController. Данные в этом объекте являются реактивными, и iron:router автоматически будет отслеживать в них изменения. Можете в консоли браузера попробовать набрать

Tracker.autorun( function() {
  console.log( 'autorun test', Session.get('var') );
} )


И попробовать изменить значение с помощью вызова Session.set('var', 'value'), результат не заставит себя ждать.

reactive_var

Именно благодаря подобному механизму, iron:router понимает когда нужно обновить подписку, таким же образом данные в шаблонах обновляются автоматически. Подробнее про реактивные переменные хорошо описано в официальной документации, и помимо переменных в Session есть возможность создать реактивные объекты, с методами set и get для управления значениями, которые тоже будут отслеживаться трекером и шаблонами. А трекер, это что-то вроде слушателя, вы можете создать функцию, которая не будет содержать реактивных переменных, но тоже будет отслеживаться трекером, для этого нужно воспользоваться Tracker.Dependency. Вообще у данной библиотеки есть и другие возможности, но на практике мне их применять не приходилось, возможно и зря.

Еще один небольшой пример, который можно выполнить в консоли браузера:

var depend = new Tracker.Dependency();
var reactFunc = function() {
  depend.depend(); return 42;
}
Tracker.autorun(function() {
  console.log( reactFunc() );
});
// 42
depend.changed()
// 42
depend.changed()
// 42


Еще немного о подписках


Я рассказал как можно использовать подписки на примере iron:router, но это не единственный механизм. Главное следует помнить, что использовать подписки нужно осторожно, иначе вы рискуете выгрузить большой объем данных и автоматически отслеживать в них изменения, там где это не нужно. iron:router предоставляет нам очень простой способ управления подписками, он сам отключит все не нужные, подключит нужные, обновит текущие в случае необходимости, как, например, это происходит при загрузке следующей страницы у нас.

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

//- client/components/users.jade
template( name='users' )
  h1 Users
  .row
    //- это данные, которые передает роутер шаблону
    +each users
      .col-xs-6.col-md-4.col-lg-3
        //- рендерим карточку пользователя
        //- в блоке each контекст меняется, поэтому мы
        //- можем не передавать в шаблон никаких параметров
        +userCard
  //- кнопка загрузки следующей страницы
  +nextPageButton

//- client/components/user_avatar/user_avatar.jade
//- унифицируем шаблон аватара, возможно понадобится добавить логику
template(name='userAvatar')
  img(src="{{user.getAvatar size}}", alt=user.getUsername, class="{{class}}")

//- client/components/user_card.jade
//- в этом шаблоне используются данные пользователя
//- а также функции описанные в модели ранее
template(name='userCard')
  .panel.panel-default
    .panel-body
      .pull-left
        +userAvatar user=this size=80

      .user-card-info-block
        ul.fa-ul
          //- сервис и имя пользователя
          li
            if isFromGithub
              i.fa.fa-li.fa-github
            else if isFromGoogle
              i.fa.fa-li.fa-google
            else
              i.fa.fa-li
            b= getName
          //- идентификатор либо логин
          li
            i.fa.fa-li @
            //- ссылка на пользователя
            a(href="{{ pathFor route='users_show' data=urlData }}")= getUsername
          //- адрес почты, если указан
          if getPublicEmail
            li
              i.fa.fa-li.fa-envelope
              = getPublicEmail


users_page

В результате у нас работает пагинация, и так как все данные реактивны, новые пользователи системы добавятся на страницу автоматически, без каких-либо перезагрузок, потому что мы подписались на коллекцию, а значит любые изменения данных в базе на сервере, сразу же будут отображены на страницу пользователя. Можете попробовать в новой вкладке зарегистрировать нового пользователя, либо поменять значения прямо в базе при помощи утилиты mongo — изменения отобразятся на странице, и вам для этого не придется ничего делать.

И чтобы убедиться в том, что данный подход работает оптимально, можно посмотреть логи браузера. Я установил количество пользователей на страницу равное одному. Протокол DDP достаточно простой и легко читаемый, поэтому не буду вдаваться в подробности. В логах можно просто увидеть, что все ненужные подписки были отписаны, а пользователи загрузились всего три раза, по одному на каждое обновление подписки.

users_log

Страница пользователя и еще немного о шаблонах

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

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

# client/routers/home.coffee
Router.route '/', name: 'home'
class @HomeController extends PagableRouteController

  # авторизован ли пользователь?
  isUserPresent: ->
    !!Meteor.userId()

  # подписываемся на профайл если пользователь авторизован
  # на сайте
  waitOn: ->
    if @isUserPresent()
      @subscribe 'profile'

  # возвращаем данные о текущем пользователе, если такой имеется
  data: ->
    if @isUserPresent()
      { user: UsersCollection.findOne Meteor.userId() }

  # рендерим шаблон профайла если пользователь авторизован
  # и домашнюю страницу в противном случае
  action: ->
    if @isUserPresent()
      @render 'profile'
    else
      @render 'home'


И также создадим контроллер, в котором можно будет просматривать профиль любого пользователя.

# client/routers/user_show.coffee
Router.route '/users/:id', name: 'users_show'
class @UsersShowController extends PagableRouteController

  # используем уже готовый шаблон
  template: 'profile'

  # подписываемся на нужного пользователя
  waitOn: ->
    @subscribe 'user', @params.id

  # ищем нужного пользователя
  data: ->
    user: UsersCollection.findOneUser(@params.id)


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

# collections/users.coffee
# ...
_.extend Users,
  # ...
  findUser: (id, options) ->
    Users.find { $or: [ { _id: id }, { username: id } ] }, options

  findOneUser: (id, options) ->
    Users.findOne { $or: [ { _id: id }, { username: id } ] }, options


Данные для страницы пользователя получить пытаемся, а они не опубликованы, исправляем это.

# server/publications/user.coffee
Meteor.publish 'user', (id) ->
  UsersCollection.findUser id,
    fields:
      service: 1
      username: 1
      profile: 1
    limit: 1


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

//- client/components/editable_field/editable_field.jade
//- вот тут солянка из вызовов хелперов
//- и обращений к данным контекста, кстати если имя хелпера
//- и свойства в текущем контексте совпадают
//- то предпочтение отдается хелперу
//- обратиться явно к контексту можно через this.<key>
template(name='editableField')
  .form-group.EditableFiled
    if data.isEditable
      div(class=inputGroupClass)
        if hasIcon
          .input-group-addon
            if icon
              i.fa.fa-fw(class='fa-{{icon}}')
            else
              i.fa.fa-fw=iconSymbol
        input.Field.form-control(placeholder=placeholder, value=value, name=name)
    else
      if defaultValue
        span.form-control-static
          if hasIcon
            if icon
              i.fa.fa-fw(class='fa-{{icon}}')
            else
              i.fa.fa-fw=iconSymbol
          = defaultValue


Для интерполяции переменных в строках, в шаблонах, можно использовать усатые конструкции: class='fa-{{icon}}', icon — это переменная.

# client/components/editable_field/editable_field.coffee
Template.editableField.helpers
  value: ->
    ObjAndPath.valueFromPath @data, @path

  name: ->
    ObjAndPath.nameFromPath @scope, @path

  hasIcon: ->
    @icon || @iconSymbol

  inputGroupClass: ->
    (@icon || @iconSymbol) && 'input-group' || ''

Template.editableField.events
  # кидаем событие выше, при изменении данных в инпуте
  'change .Field': (event, template) ->
    data  = $(event.target).serializeJSON()
    $(template.firstNode).trigger 'changed', [data]


//- client/components/profile/profile.jade
template(name='profile')
  //- смена контекста, и блок внутри не будет отрендерен,
  //- если такого свойства нет
  +with user
    .profile-left-side
      .panel.panel-default
        .panel-body
          .container-fluid
            .row.row-bottom
              //- аватар пользователя, параметром передаем конструкции
              //- вида <ключ>=<значение>, которые сложатся в один объект
              //- и станут контекстом шаблона userAvatar
              +userAvatar user=this size=200 class='profile-left-side-avatar'
            .row
              //- редактируемые поля для текущего пользователя
              +editableField fieldUsername
              +editableField fieldName
              +editableField fieldEmail

  .profile-right-side
    h1 Boards


# client/components/profile/profile.coffee
Template.profile.helpers
  fieldUsername: ->
    data:         @
    defaultValue: @getUsername()
    placeholder: 'Username'
    scope:       'user'
    path:        'username'
    iconSymbol:  '@'

  fieldName: ->
    data:         @
    defaultValue: @getName()
    placeholder: 'Name'
    scope:       'user'
    path:        'profile.name'
    icon:        'user'

  fieldEmail: ->
    data:         @
    defaultValue: @getPublicEmail()
    placeholder: 'Public email'
    scope:       'user'
    path:        'profile.email'
    icon:        'envelope'

Template.profile.events
  # отлавливаем изменения в редактируемых полях
  # и обновляем пользователя
  'changed .EditableFiled': (event, template, data) ->
    user = template.data?.user
    return unless user
    data = data.user
    user.merge data


Как мне кажется, верстка метеоровских шаблонов в jade, достаточно семантична, не нужно задумываться о многих вещах и читать кучу документации — все и так достаточно очевидно. Но если у вас возникли проблемы с пониманием кода выше, советую полистать документацию к пакету mquandalle:jade и spacebars. Просто у меня при знакомстве с версткой шаблонов в метеоре проблем не возникало, считаю, что они их, в самом деле, сделали очень удобными.

В общем все готово, открывайте форму аутентификации в шапке, входите в систему, и вместо заголовка «Home» на странице сразу же отобразится ваш профайл, без всяких перезагрузок.

profile


Если вам что-то не понятно до текущего момента, то советую ознакомится с текущим состоянием проекта в репозитарии, я старался комментировать в файлах все происходящее, также возможно стоит еще раз полистать написанное выше, может не совсем последовательно, но я старался уделить внимание всем ключевым моментам, и конечно же можно склонировать проект, на данном этапе и пощупать его руками. Дальше я собираюсь затронуть еще несколько тем, связанных с серверным кодом: как создавать свои собственные коллекции, как можно защищать данные в коллекциях от нежелательного редактирования, расскажу немного про использование RPC и использование библиотек npm на сервере.

Еще про коллекции и подписки

Прежде чем приступим к созданию своих коллекций предлагаю создать механизм, который будет автоматически вычислять некоторые поля при вставки/изменении данных в бд. Для этого добавим пакет aldeed:collection2, в который входит aldeed:simple-schema. Эти пакеты позволят нам легко валидировать данные, добавлять индексы к коллекции и прочее.

Добавим к пакету aldeed:simple-schema немного новых возможностей.

# lib/simple_schema.coffee
_.extend SimpleSchema,

  # Данный метод будет из нескольких переданных объектов
  # собирать одну схему и возвращать ее
  build: (objects...) ->
    result = {}
    for obj in objects
      _.extend result, obj
    return new SimpleSchema result

  # Если добавить к схеме данный объект,
  # то у модели появится два поля которые будут автоматически
  # вычисляться
  timestamp:
    createdAt:
      type: Date
      denyUpdate: true
      autoValue: ->
        if @isInsert
          return new Date
        if @isUpsert
          return { $setOnInsert: new Date }
        @unset()

    updatedAt:
      type: Date
      autoValue: ->
        new Date


И создадим новую коллекцию

# collections/boards.coffee
# схема данных
boardsSchema = SimpleSchema.build SimpleSchema.timestamp,
  'name':
    type: String
    index: true

  'description':
    type: String
    optional: true # не обязательное поле

  # автоматически генерируем автора доски
  'owner':
    type: String
    autoValue: (doc) ->
      if @isInsert
        return @userId
      if @isUpsert
        return { $setOnInsert: @userId }
      @unset()

  # список пользователей доски
  'users':
    type: [String]
    defaultValue: []

  'users.$':
    type: String
    regEx: SimpleSchema.RegEx.Id

# регистрируем коллекцию и добавляем схему
Boards = new Mongo.Collection 'boards'
Boards.attachSchema boardsSchema

# защита данных
Boards.allow
  # создавать доски может любой авторизованный пользователь
  insert: (userId, doc) ->
    userId && true
  # обновлять данные может только создатель доски
  update: (userId, doc) ->
    userId && userId == doc.owner

# статические методы
_.extend Boards,
  findByUser: (userId = Meteor.userId(), options) ->
    Boards.find
      $or: [
        { users: userId }
        { owner: userId }
      ]
    , options

  create: (data, cb) ->
    Boards.insert data, cb

# методы объектов
Boards.helpers
  update: (data, cb) ->
    Boards.update @_id, data, cb

  addUser: (user, cb) ->
    user = user._id if _.isObject(user)
    @update
      $addToSet:
        users: user
    , cb

  removeUser: (user, cb) ->
    user = user._id if _.isObject(user)
    @update
      $pop:
        users: user
    , cb

  updateName: (name, cb) ->
    @update { $set: {name: name} }, cb

  updateDescription: (desc, cb) ->
    @update { $set: {description: desc} }, cb

  # joins
  getOwner: ->
    UsersCollection.findOne @owner

  getUsers: (options) ->
    UsersCollection.find
      $or: [
        { _id: @owner }
        { _id: { $in: @users } }
      ]
    , options

  urlData: ->
    id: @_id

# экспорт
@BoardsCollection = Boards


Первым делом при создании коллекции мы определили схему, это позволит нам валидировать данные и автоматически вычислять некоторые поля. Подробнее о валидации можно почитать на странице пакета aldeed:simple-schema, там достаточно богатый функционал, а при установки дополнительного пакета aldeed:autoform, от тоже автора, можно генерировать формы, которые сразу же будут оповещать об ошибках, при создании записи.

Новую коллекцию в бд мы создаем вызовом Boards = new Mongo.Collection 'boards', если ее нет, либо подключаемся к существующей. В принципе это весь необходимый функционал для создания новых коллекций, там есть еще пара опций, которые можно указать при создании.

С помощью метода allow у коллекции мы можем контролировать доступ к изменению данных в коллекции. В текущем примере мы запрещаем создавать новые записи в коллекции для всех неавторизованных пользователей, и разрешаем изменять данные только для создателя доски. Эти проверки будут осуществляться на сервере и можно не переживать, что какой-нибудь кулцхакер поменяет эту логику на клиенте. Также в вашем распоряжении есть практически аналогичный метод deny, думаю суть его ясна. Подробнее про allow и deny.

При выводе карточки доски я хочу сразу отображать данные о создателе доски. Но если мы подпишемся только на доски, то эти данные поступать на клиент не будут. Однако публикации в метеоре дают возможность подписки на любые данные, даже автоматически вычисляемые, типа счетчиков коллекций и прочего.

# server/publications/boards.coffee
Meteor.publish 'boards', (userId, limit = 20) ->
  findOptions =
    limit: limit
    sort: { createdAt: -1 }

  if userId
    # доски конкретного пользователя
    cursor = BoardsCollection.findByUser userId, findOptions
  else
    # все доски
    cursor = BoardsCollection.find {}, findOptions

  inited = false
  userFindOptions =
    fields:
      service: 1
      username: 1
      profile: 1

  # колбек для добавления создателя доски к подписке
  addUser = (id, fields) =>
    if inited
      userId = fields.owner
      @added 'users', userId, UsersCollection.findOne(userId, userFindOptions)

  # отслеживаем изменения в коллекции,
  # что бы добавлять пользователей к подписке
  handle = cursor.observeChanges
    added: addUser
    changed: addUser

  inited = true
  # при инициализации сразу же добавляем пользователей,
  # при помощи одного запроса в бд
  userIds = cursor.map (b) -> b.owner
  UsersCollection.find({_id: { $in: userIds }}, userFindOptions).forEach (u) =>
    @added 'users', u._id, u

  # перестаем слушать курсор коллекции, при остановке подписки
  @onStop ->
    handle.stop()

  return cursor


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

Первым делом в зависимости от запроса мы достаем из базы нужные доски, после этого нам необходимо еще одним запросом достать пользователей. Методы added, changed и removed в контексте публикации могут управлять данными передаваемыми на клиент. Если мы в публикации возвращаем курсор коллекции, то эти методы будут вызываться автоматически в зависимости от состояния коллекции, поэтому мы и возвращаем курсор, но дополнительно в самой публикации подписываемся на изменения данных в коллекции досок, и высылаем на клиент данные о пользователях по мере необходимости.

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

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

boards

Для создания реактивных публикаций также можно использовать пакет mrt:reactive-publish.


Дорабатываем сервер

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

NPM


Для обработки изображений я привык использовать ImageMagick, и для нода есть соответствующие пакеты, которые предоставляют интерфейс к данной библиотеке. Чтобы дать метеору возможность использовать npm пакеты нужно добавить meteorhacks:npm, после этого все необходимые пакеты можно описать в файле packages.json. Например мне нужен только пакет gm и мой packages.json будет выглядеть следующим образом:

{
  "gm": "1.17.0"
}


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

Подключать npm пакеты на сервере нужно через команду Meteor.npmRequire(<pkg-name>), работает она также как и функция require в ноде.

RPC и синхронные вызовы асинхронных функций


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

# server/lib/meteor.coffee
Meteor.getUploadFilePath = (filename) ->
  "#{process.env.PWD}/.uploads/#{filename}"

# server/methods/upload_board_image.coffee
# подключаем библиотеку для обработки изображения
gm = Meteor.npmRequire 'gm'

# ресайз и сохранение изображения
resizeAndWriteAsync = (buffer, path, w, h, cb) ->
  gm(buffer)
  .options({imageMagick: true})
  .resize(w, "#{h}^", ">")
  .gravity('Center')
  .crop(w, h, 0, 0)
  .noProfile()
  .write(path, cb)

# делаем обработку изображения синхронной
resizeAndWrite = Meteor.wrapAsync resizeAndWriteAsync

# регистрируем метод для загрузки изображения к доске
Meteor.methods
  uploadBoardImage: (boardId, data) ->
    board = BoardsCollection.findOne(boardId)
    if board.owner != @userId
      throw new Meteor.Error('notAuthorized', 'Not authorized')

    data  = new Buffer data, 'binary'
    name  = Meteor.uuid() # уникальное имя для изображения
    path  = Meteor.getUploadFilePath name

    resizeAndWrite data, "#{path}.jpg", 1920, 1080
    resizeAndWrite data, "#{path}_thumb.jpg", 600, 400

    # сохраняем данные к доске
    BoardsCollection.update { _id: boardId },
      $set:
        background:
          url:   "/uploads/#{name}.jpg"
          thumb: "/uploads/#{name}_thumb.jpg"
    return


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

Если в методе будет брошено исключение, то оно передастся пользователю на клиент, первым параметром коллбека. А данные возвращенные методом придут на клиент вторым параметром коллбека.

Чтобы можно было использовать исключения и возвраты функций при асинхронном стиле программирования, в серверной части метеора есть метод оборачивающий асинхронные функции в синхронные, через библиотеку fibers. Если в кратце, благодаря этой библиотеке, вызовы обернутых функций не будут занимать очередь выполнения, так что на сервере можно писать синхронный код и не беспокоится о неправильной последовательности выполнения кода. Методом Meteor.wrapAsync(<async-func>) оборачиваются функции, которые последним параметром принимают коллбек. В этом коллбеке первым параметром должна идти ошибка, а вторым результат, такой формат параметров у всех стандартных библиотек в ноде. Если придет ошибка, то обернутая функция бросит исключение с этой ошибкой, иначе из функции вернется второй параметр переданный в коллбек.

Роутинг

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

В метеоре для серверного роутинга есть стандартный пакет webapp, но у нас уже установлено гораздо более удобное решение в виде iron:router. Аналогично, как и на клиенте, создадим серверный маршрут.

# server/routes/uploads.coffee
fs = Meteor.npmRequire 'fs'

Router.route '/uploads/:file',
  where: 'server'
  action: ->
    try
      filepath = Meteor.getUploadFilePath(@params.file)
      file = fs.readFileSync(filepath)
      @response.writeHead 200, { 'Content-Type': 'image/jpg' }
      @response.end file, 'binary'
    catch e
      @response.writeHead 404, { 'Content-Type': 'text/plain' }
      @response.end '404. Not found.'


Здесь главное роуту передать свойство where: 'server', иначе он не будет работать. В действии мы пытаемся считать с диска указанный файл, так как в этой директории будут только изображения одного формата, я максимально упростил данный метод.

Объекты request и response доступные в контексте роута, это объекты классов из стандартной библиотеки нода http.IncomingMessage и http.ServerResponse соответственно.

В iron:router есть еще интерфейс для создания REST API.

Использование RPC


Для использования давайте создадим форму добавления новой доски.

Тут я еще создал автокомплит для добавления пользователей к доске, там также используется RPC, подробнее с реализацией можно ознакомится в репозитарии.

//- client/components/new_board_form/new_board_form.jade
template(name='newBoardForm')
  //- панель с динамическими стилями
  .panel.panel-default.new-board-panel(style='{{panelStyle}}')
    .panel-body
      h1 New board
      form(action='#')
        .form-group
          input.form-control(type='text',placeholder='Board name',name='board[name]')
        .form-group
          textarea.form-control(placeholder='Description',name='board[description]')
        .form-group
          //- прячем инпут с файлом, но оставляем метку на этот инпут, для красоты
          label.btn.btn-default(for='newBoardImage') Board image
          .hide
            input#newBoardImage(type='file', accept='image/*')
        button.btn.btn-primary(type='submit') Submit


# client/components/new_board_form/new_board_form.coffee
# переменные для текущего пользовательского изображения
currentImage = null
currentImageUrl = null
currentImageDepend = new Tracker.Dependency

# сброс пользовательского изображения
resetImage = ->
  currentImage = null
  currentImageUrl = null
  currentImageDepend.changed()

# загрузка изображения на сервер
uploadImage = (boardId) ->
  if currentImage
    reader = new FileReader
    reader.onload = (e) ->
      # вызов серверного метода
      Meteor.call 'uploadBoardImage', boardId, e.target.result, (error) ->
        if error
          alertify.error error.message
        else
          alertify.success 'Image uploaded'
    reader.readAsBinaryString currentImage

# хелперы шаблона формы
Template.newBoardForm.helpers
  # задаем фоновое изображение для формы,
  # функция будет вызываться автоматически, так как имеет зависимость
  panelStyle: ->
    currentImageDepend.depend()
    currentImageUrl && "background-image: url(#{currentImageUrl})" || ''

# данный колбек срабатывает каждый раз, когда форма рендерится на страницу
Template.newBoardForm.rendered = ->
  resetImage()

# события формы
Template.newBoardForm.events
  # при отправки формы, мы создаем новую запись
  # если все прошло хорошо, загружаем изображение,
  # и сбрасываем форму
  'submit form': (event, template) ->
    event.preventDefault()
    form = event.target
    data = $(form).serializeJSON()
    BoardsCollection.create data.board, (error, id) ->
      if error
        alertify.error error.message
      else
        form.reset()
        alertify.success 'Board created'
        resetUsers()
        uploadImage(id)
        resetImage()

  # при выборе изображения меняем фон формы
  # и запоминаем текущий выбор
  'change #newBoardImage': (event, template) ->
    files = event.target.files
    image = files[0]
    unless image and image.type.match('image.*')
      resetImage()
      return

    currentImage = image

    reader = new FileReader
    reader.onload = (e) =>
      currentImageUrl = e.target.result
      currentImageDepend.changed()

    reader.readAsDataURL(image)


Тут для загрузки и обработки изображения мы выполняем удаленный метод через Meteor.call. Как видно, вызов удаленный процедуры на клиенте, мало чем отличается от обычного вызова функции, а все данные, переданные аргументами, будут загружены на сервер по веб-сокету. Для чтения пользовательских файлов я воспользовался File API из спецификации HTML5.

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

new_board

  • Результат, загрузка картинок там не заработала, толи дело в ImageMagick, толи в невозможности создать свою директорию для хранения
  • Репозитарий

В принципе это все, что я хотел осветить в данном уроке, но для завершенности, доделаю функционал карточек в досках, там не будет использоваться что-то, чего нет в данном уроке. С изменениями можно как обычно ознакомится в репозитарии.

tasks



Ссылки

Share post

Comments 19

    +4
    Отличная статья! Только есть поправочка, перед установкой meteor не нужно ставить node и mongo, он ставит их сам.
      +1
      Отличная и очень детальная статья, спасибо!
        –15
        Не могу не пропиариться, вот моя реализация шуточного туду листа на Meteor.js bte.meteor.com
        Осторожно 18+
        Все лежит на гитхабе github.com/Akurganow/Best-ToDo-Ever
          +11
          Ого, вот это пропиарился O_o
          +1
          Готовый пример туду-листа: todos.meteor.com/

          meteor create --example todos
            0
            Вот так урок!
            Я сам только обратил внимание на метеор, думаю что будет мне полезно.
              +3
              А «убийца веба», видимо для тех, кто «вебом» считает пэхэпэ и масикуль?
                +1
                Прелесть. Спасибо за отличные примеры, и наглядность. Похожие впечатления на меня когда-то произвёл AngularJS
                  –2
                  Великолепно. Осталось разрешить небольшие баги и все будет еще круче
                  Скрытый текст
                  image
                    0
                    Появился соблазн попробовать Meteor в каком-нибудь будущем проекте.
                      +12
                      Было бы круто, если бы примеры кода были на нативной javascript, а не на coffeescript, который не все знают/любят/легко воспринимают — это же туториал.
                        +9
                        По моему, в каждом посте, где есть код на coffeescript, присутствует подобный вашему комментарий :)
                          +6
                          Потому что CS делает многим людям больно, у меня вот реально глаза болят от него, хоть и понимаю.
                            +1
                            Потому что люди заходят разобраться в Метеоре, а приходится изучать Кофе, которое их не интересует. Я вот мятный чай предпочитаю.
                            0
                            js2coffee.org в помощь!
                            0
                            Windows поддерживает, но далеко не последнюю версию. Сейчас, кажется, доступна 0.9.0.1
                            • UFO just landed and posted this here
                                0

                                Уже прошло достаточно много времени, и данный мануал во многом потерял свою актуальность, особенно что касается конкретных решений. Я бы рекомендовал обратиться в первую очередь к официальному гайду, так как материалы там теперь хорошо проработаны, содержат актуальную информацию и лучшие практики.


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

                                • UFO just landed and posted this here

                              Only users with full accounts can post comments. Log in, please.