В данном уроке я не хочу обсуждать, почему метеор убийца веба, тем более я так не считаю, но определенную симпатию к этому фреймворку имею. Поэтому хочу показать с чего можно начать при разработке приложения на нем, какие есть пакеты и вообще, что такое этот метеор.
Сразу хочу сказать, что у меня нет большого опыта в разработке веб приложений. Я занимаюсь этим всего около двух лет, а с метеором знаком вообще лишь пару месяцев.
Еще хочу предупредить, что урок получился достаточно объемным, но кода в нем было написано в разы меньше, чем текста. Просто хочу поделиться опытом как можно использовать метеор не при создании простенького примера, и заострить внимание на различных моментах, которые я посчитал важными. Поэтому в уроке будет использоваться множество сторонних пакетов, облегчающих процесс разработки.
И еще одно предупреждение: в данном уроке будут использоваться следующие технологии для непосредственного написания примера:
- jade - html препроцессор;
- less - css препроцессор;
- coffeescript - язык программирования, компилируемый в javascript.
Видео, демонстрирующее приложение, полученное в ходе урока
И кому все еще интересно, добро пожаловать под кат.
+-------------+
| Продолжение |
+-------------+
Сам метеор базируется на 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/
Данный вывод означает, что все прошло хорошо, и наш хелловорлд можно проверить в браузере.
Теперь, после проверки работоспособности нового проекта, файлы, находящиеся в корне проекта, можно удалить - нам они не особо интересны. Также можно заметить, что была создана директория .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;
}
}
При изменениях стилей, кстати, не требуется обновлять страницу в браузере, достаточно сохранить файл, и они сразу же применятся, вот такой удобный инструмент из коробки есть для верстальщиков в метеоре.
В самом метеоре нет стандартного механизма роутинга, я предлагаю использовать пакет 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
Можно открыть браузер и убедиться, что наш роутинг работает, покликав на ссылки в шапке. Причем работает без обновления страницы.
По ходу разработки данного урока я попытаюсь все изменения в коде вносить в репозиторий, в соответствии с последовательностью изложения, что бы вы могли проследить весь процесс, так как в посте некоторые вещи могут быть пропущены. Репозитарий.
Здесь можно посмотреть, что получилось в итоге, а здесь посмотреть код проекта в текущем состоянии.
Многие технические задания, приходящие к нам в компанию, первой задачей описывают систему пользователей. Так как это довольно распространенная задача, считаю необходимым и в нашем уроке рассмотреть способы аутентификации пользователей, тем более метеор для этого предоставляет стандартные средства.
Мы не будем сильно углубляться в механизмы, а просто используем готовые решения, которые позволят нам создавать пользователей через логин/пароль или сервисы 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
И получим следующий результат
После конфигурации можем убедиться, что токены авторизации сохранились в базе данных.
$ 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
А когда пытаюсь получить данные в браузере, то не нашел ни одной записи без аутентификации и одну (собственную) в противном случае.
В данном приложении не будем скрывать пользователей от всех, просто скроем приватную информацию, вроде токенов аутентификации.
Так как мы удалили пакет 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')
, результат не заставит себя ждать.
Именно благодаря подобному механизму, 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
В результате у нас работает пагинация, и так как все данные реактивны, новые пользователи системы добавятся на страницу автоматически, без каких-либо перезагрузок, потому что мы подписались на коллекцию, а значит любые изменения данных в базе на сервере, сразу же будут отображены на страницу пользователя. Можете попробовать в новой вкладке зарегистрировать нового пользователя, либо поменять значения прямо в базе при помощи утилиты mongo
- изменения отобразятся на странице, и вам для этого не придется ничего делать.
И чтобы убедиться в том, что данный подход работает оптимально, можно посмотреть логи браузера. Я установил количество пользователей на страницу равное одному. Протокол DDP достаточно простой и легко читаемый, поэтому не буду вдаваться в подробности. В логах можно просто увидеть, что все ненужные подписки были отписаны, а пользователи загрузились всего три раза, по одному на каждое обновление подписки.
Давайте создадим страницу пользователя, где будет возможность изменять некоторые данные и завершим на этом работу с пользователями, чтобы можно было перейти к созданию собственных коллекций.
Для этого первым делом для авторизованного пользователя вместо домашней страницы будем показывать страницу текущего пользователя, модифицируем немного контроллер.
# 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" на странице сразу же отобразится ваш профайл, без всяких перезагрузок.
Если вам что-то не понятно до текущего момента, то советую ознакомится с текущим состоянием проекта в репозитарии, я старался комментировать в файлах все происходящее, также возможно стоит еще раз полистать написанное выше, может не совсем последовательно, но я старался уделить внимание всем ключевым моментам, и конечно же можно склонировать проект, на данном этапе и пощупать его руками. Дальше я собираюсь затронуть еще несколько тем, связанных с серверным кодом: как создавать свои собственные коллекции, как можно защищать данные в коллекциях от нежелательного редактирования, расскажу немного про использование 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
в контексте публикации могут управлять данными передаваемыми на клиент. Если мы в публикации возвращаем курсор коллекции, то эти методы будут вызываться автоматически в зависимости от состояния коллекции, поэтому мы и возвращаем курсор, но дополнительно в самой публикации подписываемся на изменения данных в коллекции досок, и высылаем на клиент данные о пользователях по мере необходимости.
С помощью логов соединения по веб-сокетам либо при помощи данной утилиты, можно убедиться, что подобный подход будет работать оптимально. И тут важно понимать, что в нашем случае изменения в коллекции пользователей не будут синхронизироваться с клиентом, но так и задумывалось. Кстати для простого "джоина" можно просто возвращать массив курсоров в результате подписки.
Для отображения досок пользователей, я добавил новые подписки в роутеры и заверстал необходимые шаблоны, но все эти моменты мы уже рассмотрели выше, если вам интересны все изменения, то их можно увидеть здесь. А в итоге мы должны получить следующее, правда доски придется создавать через консоль, что бы проверить работоспособность.
Для создания реактивных публикаций также можно использовать пакет mrt:reactive-publish.
Давайте для досок добавим возможность задавать фоновое изображение, для этого нам необходимо настроить сервер, чтобы он смог принимать файлы, обрабатывать их, сохранять и отдавать при запросе.
Для обработки изображений я привык использовать ImageMagick, и для нода есть соответствующие пакеты, которые предоставляют интерфейс к данной библиотеке. Чтобы дать метеору возможность использовать npm
пакеты нужно добавить meteorhacks:npm
, после этого все необходимые пакеты можно описать в файле packages.json
. Например мне нужен только пакет gm и мой packages.json
будет выглядеть следующим образом:
{
"gm": "1.17.0"
}
Все пакеты npm
подключенные через meteorhacks:npm
буду оборачиваться в один метеоровский пакет, поэтому при сборке приложения через команду meteor build
не возникнет никаких проблем и все зависимости автоматически разрешаться.
Подключать npm
пакеты на сервере нужно через команду Meteor.npmRequire(<pkg-name>)
, работает она также как и функция require
в ноде.
Для загрузки и обработки изображения создадим серверный метод, который можно будет вызывать с клиента.
# 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, подробнее с реализацией можно ознакомится в репозитарии.
//- 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.
- Результат, загрузка картинок там не заработала, толи дело в ImageMagick, толи в невозможности создать свою директорию для хранения
- Репозитарий
В принципе это все, что я хотел осветить в данном уроке, но для завершенности, доделаю функционал карточек в досках, там не будет использоваться что-то, чего нет в данном уроке. С изменениями можно как обычно ознакомится в репозитарии.
- Meteor
- Документация;
- Туториал от разработчиков фреймворка;
- Atmosphere - пакеты для метеора
- mquandalle:jade - пакет добавляющий возможность использовать jade разметку для шаблонов метеора
- iron:router - клиентский и серверный роутинг;
- mizzao:bootstrap-3 - Twitter Bootstrap для метеора;
- ian:accounts-ui-bootstrap-3 - шаблоны на основе Twitter Bootstrap для форм входа и регистрации;
- dburles:collection-helpers - с помощью этого пакета можно добавить логику к объектам модели;
- jparker:gravatar - генерация ссылок на изображения в сервисе Gravatar;
- aldeed:collection2 - дополнительные возможности к метеоровским коллекциям;
- sergeyt:typeahead - автокомплит для бутсрапа;
- meteorhacks:npm - простая интеграция
npm
пакетов в метеор приложение; - CollectionFS - управление файлами;
- Полезные ресурсы:
- MeteorHacks - сборник туториалов;
- EventedMind - видео уроки;
- Meteorpedia - неофициальная Wiki;
- Еще множество ресурсов;
- Репозтарий проекта, разрабатываемого в этом уроке