
- Введение и выбор стека технологий
- Начальная настройка проекта Phoenix Framework
- Модель User и JWT-аутентификация
- Front-end для регистрации на React и Redux
- Начальное заполнение базы данных и контроллер для входа в приложение
- Аутентификация на front-end на React и Redux
- Настраиваем сокеты и каналы
- Выводим список и создаём новые доски
- Добавляем новых пользователей досок
- Отслеживаем подключённых пользователей досок
- Добавляем списки и карточки
- Выкладываем проект на Heroku
Эта часть — заключительная, и будет особенно длинной, но хочется уже закончить с циклом и пойти дальше. Так же прошу прощения за столь огромную паузу при её подготовке и публикации. Однако это время не прошло даром и дало материал для новых, на этот раз оригинальных, статей — прим. переводчика
Отслеживаем подключение участников досок
Предупреждение от автора: эта часть была написана до появления функциональности Presence и является небольшим введением в основы поведения GenServer.
Вспомним предыдущую часть, в которой мы предоставили нашим пользователям возможность приглашать новых участников на свои доски. При добавлении e-mail существующего пользователя создавалась новая взаимосвязь между пользователями и досками, а данные нового пользователя передавались через канал (channel), в результате чего его аватар отображался всем участникам доски, находящимся онлайн. На первый взгляд это круто, но мы можем сделать гораздо лучше и полезнее, если сможем просто выделить пользователей, которые в настоящий момент находятся online и просматривают доску. Давайте начнём!
Проблема
Прежде, чем продолжить, давайте задумаемся о том, чего хотим достичь. Так, по сути, у нас есть доска и несколько участников, которые могут неожиданно посетить её url, автоматически подключаясь к каналу доски. Когда это случается, аватар участника должен быть показан без прозрачности, в противоположность участникам, находящимся офлайн, аватары которых должны быть полупрозрачными.

Когда подключённый участник покидает url доски, выходит из приложения или даже закрывает окно браузера, нам нужно оповестить об этом событии всех подключённых к каналу доски пользователей, чтобы его аватар снова стал полупрозрачным, уведомляя, что пользователь более не просматривает доску. Давайте рассмотрим несколько способов, которыми мы можем этого достичь и их недостатки:
- Управление списком подключённых участников на front-end в хранилище Redux. На первый взгляд это может выглядеть подходящим решением, но оно будет работать только для участников, уже подключившихся к каналу доски. Недавно подключившиеся пользователи не будут иметь этих данных.
- Использовать базу данных для хранения списка подключившихся участников. Это тоже может оказаться подходящим способом, но заставит нас постоянно дёргать базу данных запросами списка участников и его обновлениями при любом подключении или выходе участника, не говоря уже о смешевании данных с весьма специфическим поведением пользователя.
Так где мы можем хранить эту информацию так, чтобы дать к ней доступ для всех пользователей быстро и эффективно? Легко. В… наберитесь терпения… перманентном сохраняющем состояние процессе.
Принципы GenServer
Хотя фраза перманентный сохраняющий состояние процесс может поначалу звучать устрашающе, реализовать это гораздо проще, чем можно было бы ожидать, благодаря Elixir и его GenServer.
GenServer — это процесс, подобный любому другому процессу Elixir, и он может использоваться для хранения состояния, асинхронного выполнения кода и тому подобного.
Представьте себе это как маленький процесс, выполняющийся на нашем сервере и имеющий ассоциативный массив (map), содержащий для каждой доски список id подключившихся пользователей. Что-то вроде такого:
%{
"1" => [1, 2, 3],
"2" => [4, 5]
}
Теперь представьте, что этот процесс имеет доступный интерфейс для собственной инициализации и обновления ассоциативного массива состояния, для добавления и удаления досок и подключившихся пользователей. Чтож, это, в целом, процесс GenServer, и я говорю "в целом" постольку, поскольку он так же будет иметь соответствующие преимущества вроде трасировки, отчёта об ошибках и возможностями отслеживания (supervision).
Монитор BoardChannel
Итак, создадим самую начальную версию этого процесса, который будет хранить данные отслеживания списка подключившихся участников доски:
# /lib/phoenix_trello/board_channel/monitor.ex
defmodule PhoenixTrello.BoardChannel.Monitor do
use GenServer
#####
# Client API
def start_link(initial_state) do
GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
end
end
Работая с GenServer необходимо продумать как функции API для внешних клиентов, так и их серверную реализацию. Первым делом необходимо реализовать функцию start_link
, которая будет по-настоящему запускать GenServer, в качестве аргумента передавая в него начальное состояние, — в нашем случае пустой ассоциативный массив, — между именем модуля и названием сервера. Мы хотим стартовать этот процесс во время запуска приложения, так что добавим его в список потомков в нашем дереве отслеживания (supervision tree):
# /lib/phoenix_trello.ex
defmodule PhoenixTrello do
use Application
def start(_type, _args) do
import Supervisor.Spec, warn: fals
e
children = [
# ...
worker(PhoenixTrello.BoardChannel.Monitor, [%{}]),
# ...
]
# ...
end
end
Теперь каждый раз при запуске приложения оно будет автоматически вызывать функцию start_link
, которую мы только что создали, передавая ей в качестве начального состояния пустой ассоциативный массив %{}
. Если исполнение Monitor
прервётся по любой причине, приложение запустит его заново с новым пустым ассоциативным массивом. Здорово, не правда ли? Теперь, настроив всё это, давайте начнём добавлять участников в массив состояний Monitor
'а.
Обработка подключений участников
Для этого нам понадобится добавить и клиентскую функцию, и соответствующий ей серверный обработчик функции обратной связи (далее просто callback-функции):
# /lib/phoenix_trello/board_channel/monitor.ex
defmodule PhoenixTrello.BoardChannel.Monitor do
use GenServer
#####
# Client API
# ...
def member_joined(board, member) do
GenServer.call(__MODULE__, {:member_joined, board, member})
end
#####
# Server callbacks
def handle_call({:member_joined, board, member}, _from, state) do
state = case Map.get(state, board) do
nil ->
state = state
|> Map.put(board, [member])
{:reply, [member], state}
members ->
state = state
|> Map.put(board, Enum.uniq([member | members]))
{:reply, Map.get(state, board), state}
end
end
end
При вызове функции member_joined/2
, передавая ей доску и пользователя, мы будем совершать обращение к процессу GenServer с сообщением {:member_joined, board, member}
. По этой причине нам нужен серверный обработчик callback-функции. Callback-функция handle_call/3
из GenServer
получает сообщение-запрос, отправителя и текущее состояние. Так что в нашем случае мы попытаемся получить доску из состояния и добавить пользователя в её список пользователей. В случае, если доски ещё нет, мы добавим её с новым списком, содержащим подключившегося пользователя. В качестве ответа мы вернём список пользователей, принадлежащий этой доске.
Откуда стоит вызвать метод member_joined
? Из BoardChannel в момент подключения пользователя:
# /web/channels/board_channel.ex
defmodule PhoenixTrello.BoardChannel do
use PhoenixTrello.Web, :channel
alias PhoenixTrello.{User, Board, UserBoard, List, Card, Comment, CardMember}
alias PhoenixTrello.BoardChannel.Monitor
def join("boards:" <> board_id, _params, socket) do
current_user = socket.assigns.current_user
board = get_current_board(socket, board_id)
connected_users = Monitor.user_joined(board_id, current_user.id)
send(self, {:after_join, connected_users})
{:ok, %{board: board}, assign(socket, :board, board)}
end
def handle_info({:after_join, connected_users}, socket) do
broadcast! socket, "user:joined", %{users: connected_users}
{:noreply, socket}
end
# ...
end
Таким образом, когда он подключается, мы используем Monitor
для его отслеживания, и рассылаем через сокет обновлённый список текущих пользователей доски. Теперь мы можем обработать эту рассылку на фронт-энде, чтобы обновить состояние приложения новым списком подключенных пользователей:
// /web/static/js/actions/current_board.js
import Constants from '../constants';
const Actions = {
// ...
connectToChannel: (socket, boardId) => {
return dispatch => {
const channel = socket.channel(`boards:${boardId}`);
// ...
channel.on('user:joined', (msg) => {
dispatch({
type: Constants.CURRENT_BOARD_CONNECTED_USERS,
users: msg.users,
});
});
};
}
}
Единственное, что осталось сделать — изменить прозрачность аватара в зависимости от того, указан ли участник доски в этом списке или нет:
// /web/static/js/components/boards/users.js
export default class BoardUsers extends React.Component {
_renderUsers() {
return this.props.users.map((user) => {
const index = this.props.connectedUsers.findIndex((cu) => {
return cu.id === user.id;
});
const classes = classnames({ connected: index != -1 });
return (
<li className={classes} key={user.id}>
<ReactGravatar className="react-gravatar" email={user.email} https/>
</li>
);
});
}
// ...
}
Обработка отключений пользователей
Процесс отключения пользователя от канала доски почти такой же. Для начала давайте обновим Monitor
, добавив необходимую клиентскую функцию и соответствующую ей серверную callback-функцию:
# /lib/phoenix_trello/board_channel/monitor.ex
defmodule PhoenixTrello.BoardChannel.Monitor do
use GenServer
#####
# Client API
# ...
def member_left(board, member) do
GenServer.call(__MODULE__, {:member_left, board, member})
end
#####
# Server callbacks
# ...
def handle_call({:member_left, board, member}, _from, state) do
new_members = state
|> Map.get(board)
|> List.delete(member)
state = state
|> Map.update!(board, fn(_) -> new_members end)
{:reply, new_members, state}
end
end
Как вы можете увидеть, это почти та же функциональность, что и у member_join
, но развёрнутая в обратном порядке. В функции происходит поиск доски в состоянии и удаление участника, а затем замена текущего списка участников доски новым и его возврат в ответе. Так же, как и в случае с подключением, мы будем вызывать эту функцию из BoardChannel, так что давайте его обновим:
# /web/channels/board_channel.ex
defmodule PhoenixTrello.BoardChannel do
use PhoenixTrello.Web, :channel
# ...
def terminate(_reason, socket) do
board_id = Board.slug_id(socket.assigns.board)
user_id = socket.assigns.current_user.id
broadcast! socket, "user:left", %{users: Monitor.user_left(board_id, user_id)}
:ok
end
end
Когда подключение к каналу прерывается, обработчик разошлёт обновлённый список участников через сокет, как мы делали и до этого. Для прервания подключения к каналу мы создадим конструктор действия (action creator), которым воспользуемся при отмонтировании представления текущей доски; так же нам нужно добавить обработчик для рассылки user:left
:
// /web/static/js/actions/current_board.js
import Constants from '../constants';
const Actions = {
// ...
connectToChannel: (socket, boardId) => {
return dispatch => {
const channel = socket.channel(`boards:${boardId}`);
// ...
channel.on('user:left', (msg) => {
dispatch({
type: Constants.CURRENT_BOARD_CONNECTED_USERS,
users: msg.users,
});
});
};
},
leaveChannel: (channel) => {
return dispatch => {
channel.leave();
};
},
}
Не забудьте обновить компонент BoardShowView
, чтобы при его отмонтировании обработать конструктор действия leaveChannel
:
// /web/static/js/views/boards/show.js
import Actions from '../../actions/current_board';
// ...
class BoardsShowView extends React.Component {
// ...
componentWillUnmount() {
const { dispatch, currentBoard} = this.props;
dispatch(Actions.leaveChannel(currentBoard.channel));
}
}
// ...
И на этом всё! Чтобы протестировать получившееся, просто откройте два разных браузера и войдите в приложение под разными пользователями. Затем перейдите на одну и ту же доску в обоих и поиграйтесь, входя и выходя из доски одним из пользователей. Вы увидите, как прозрачность его аватара будет меняться туда и обратно, что довольно клёво.
Я надеюсь, что вы насладились работой с GenServer так же, как и я в первый раз. Но мы затронули только малую часть. GenServer и Supervisor — очень богатые инструменты из предлагаемых Elixir, причём полностью интегрированные и пуленепробиваемые (в оригинале автор употребляет термин bullet proof, подразумевая, видимо, имеющуюся в Erlang/Elixir функциональность по отслеживанию цикла жизни процессов и их перезапуску в случае необходимости — прим. переводчика), не требующие для работы сторонних зависимостей — в противоположность, например, Redis. В слещующей части мы продолжим создание списков и карточек в реальном времени при помощи сокетов и каналов.
Добавляем списки и карточки
В предыдущей части мы создали простой, но уже полезный механизм для отслеживания подключённых к каналу доски пользователей с помощью OTP и функциональности GenServer. Мы также научились рассылать этот список через канал, так что каждый участник сможет видеть, кто ещё просматривает доску в то же самое время. Теперь пришло время позволить участникам добавить несколько карточек и списков, в то время как изменения будут появляться на их экранах немедленно… Сделаем это!
Миграции и модели
Доска (Board
) может иметь несколько списков (lists), которые, в свою очередь, также могут иметь несколько карточек, так что держа это в голове давайте начнём с генерации модели List
, используя в консоли следующую задачу mix
:
$ mix phoenix.gen.model List lists board_id:references:board name:string
...
...
$ mix ecto.migrate
Этим мы создадим в базе данных таблицу lists
и соответствующую модель:
# web/models/list.ex
defmodule PhoenixTrello.List do
use PhoenixTrello.Web, :model
alias PhoenixTrello.{Board, List}
@derive {Poison.Encoder, only: [:id, :board_id, :name]}
schema "lists" do
field :name, :string
belongs_to :board, Board
timestamps
end
@required_fields ~w(name)
@optional_fields ~w()
def changeset(model, params \\ :empty) do
model
|> cast(params, @required_fields, @optional_fields)
end
end
Генерация модели Card
происходит очень похоже:
$ mix phoenix.gen.model Card cards list_id:references:lists name:string
...
...
$ mix ecto.migrate
Результирующая модель будет выглядеть как-то так:
# web/models/card.ex
defmodule PhoenixTrello.Card do
use PhoenixTrello.Web, :model
alias PhoenixTrello.{Repo, List, Card}
@derive {Poison.Encoder, only: [:id, :list_id, :name]}
schema "cards" do
field :name, :string
belongs_to :list, List
timestamps
end
@required_fields ~w(name list_id)
@optional_fields ~w()
def changeset(model, params \\ :empty) do
model
|> cast(params, @required_fields, @optional_fields)
end
end
Не забудьте добавить набор карточек к схеме lists
:
# web/models/list.ex
defmodule PhoenixTrello.List do
# ...
@derive {Poison.Encoder, only: [:id, :board_id, :name, :cards]}
# ...
schema "lists" do
# ..
has_many :cards, Card
end
# ...
end
Теперь мы можем двинуться вперёд, к фронтэнду, и создать необходимые компоненты.
Компонент формы списка
Прежде, чем продолжить, вспомним функцию render
компонента BoardsShowView
:
// web/static/js/views/boards/show.js
//...
//...
_renderLists() {
const { lists, channel, id, addingNewCardInListId } = this.props.currentBoard;
return lists.map((list) => {
return (
<ListCard
key={list.id}
boardId={id}
dispatch={this.props.dispatch}
channel={channel}
isAddingNewCard={addingNewCardInListId === list.id}
{...list} />
);
});
}
render() {
const { fetching, name } = this.props.currentBoard;
if (fetching) return (
<div className="view-container boards show">
<i className="fa fa-spinner fa-spin"/>
</div>
);
return (
<div className="view-container boards show">
<header className="view-header">
<h3>{name}</h3>
{::this._renderMembers()}
</header>
<div className="canvas-wrapper">
<div className="canvas">
<div className="lists-wrapper">
{::this._renderLists()}
{::this._renderAddNewList()}
</div>
</div>
</div>
{this.props.children}
</div>
);
}
В отличие от компонента BoardMembers
, который мы создали последним, нам также понадобится отрисовать все списки, относящиеся к текущей доске. На данный момент у нас нет никаких списков, поэтому перейдём к функции _renderAddNewList
:
// web/static/js/views/boards/show.js
// ...
_renderAddNewList() {
const { dispatch, formErrors, currentBoard } = this.props;
if (!currentBoard.showForm) return this._renderAddButton();
return (
<ListForm
dispatch={dispatch}
errors={formErrors}
channel={currentBoard.channel}
onCancelClick={::this._handleCancelClick} />
);
}
_renderAddButton() {
return (
<div className="list add-new" onClick={::this._handleAddNewClick}>
<div className="inner">
Add new list...
</div>
</div>
);
}
_handleAddNewClick() {
const { dispatch } = this.props;
dispatch(Actions.showForm(true));
}
_handleCancelClick() {
this.props.dispatch(Actions.showForm(false));
}
// ...
Функция _renderAddNewList
для начала проверяет, выставлено ли в true
свойство currentBoard.showForm
, так что она отрисовывает кнопку Добавить новый список... вместо компонента ListForm
.
Когда пользователь нажмёт кнопку, соответствующее действие (action) будет направлено в хранилище и установит свойство showForm
в true
, что вызовет отображение формы. Теперь создадим компонент формы:
// web/static/js/components/lists/form.js
import React, { PropTypes } from 'react';
import Actions from '../../actions/lists';
export default class ListForm extends React.Component {
componentDidMount() {
this.refs.name.focus();
}
_handleSubmit(e) {
e.preventDefault();
const { dispatch, channel } = this.props;
const { name } = this.refs;
const data = {
name: name.value,
};
dispatch(Actions.save(channel, data));
}
_handleCancelClick(e) {
e.preventDefault();
this.props.onCancelClick();
}
render() {
return (
<div className="list form">
<div className="inner">
<form id="new_list_form" onSubmit={::this._handleSubmit}>
<input ref="name" id="list_name" type="text" placeholder="Add a new list..." required="true"/>
<button type="submit">Save list</button> or <a href="#" onClick={::this._handleCancelClick}>cancel</a>
</form>
</div>
</div>
);
}
}

Это очень простой компонент с формой, содержащей текстое поле для названия списка, кнопкой отправки и ссылкой на отмену, которая будет направлять то же действие, что мы описали, но устанавливая showForm
в false
, чтобы спрятать форму. Когда форма отправлена, компонент вместе с именем пользователя направит конструктор действия save
, который отправит имя на тему lists:create
канала BoardChannel
:
// web/static/js/actions/lists.js
import Constants from '../constants';
const Actions = {
save: (channel, data) => {
return dispatch => {
channel.push('lists:create', { list: data });
};
},
};
export default Actions;
BoardChannel
Следующим шагом нужно научить BoardChannel
обрабатывать сообщение lists:create
, так что займёмся этим:
# web/channels/board_channel.ex
defmodule PhoenixTrello.BoardChannel do
# ...
def handle_in("lists:create", %{"list" => list_params}, socket) do
board = socket.assigns.board
changeset = board
|> build_assoc(:lists)
|> List.changeset(list_params)
case Repo.insert(changeset) do
{:ok, list} ->
list = Repo.preload(list, [:cards])
broadcast! socket, "list:created", %{list: list}
{:noreply, socket}
{:error, _changeset} ->
{:reply, {:error, %{error: "Error creating list"}}, socket}
end
end
# ...
end
Используя доску, прикреплённую к каналу, функция выстроит набор изменений (changeset) модели List
на основе полученных параметров (list_params
) и добавит его в базу. Если всё будет :ok
, будет проведена рассылка созданного списка через канал всем подключенным пользователям, включая создателя, поэтому нам не нужно что-то отвечать, и мы возвращаем просто :noreply
. Если же каким-то чудом во время добавления нового списка возникнет ошибка, сообщение об ошибке будет возвращено только создателю, так что он будет знать, что что-то пошло не так.
Преобразователь
Мы почти закончили со списками. Канал рассылает созданный лист, так что добавим обработчик этого на фронтэнд в конструктор действий текущей доски, где происходило подключение к каналу:
// web/static/js/actions/current_board.js
import Constants from '../constants';
const Actions = {
// ...
connectToChannel: (socket, boardId) => {
return dispatch => {
const channel = socket.channel(`boards:${boardId}`);
// ...
channel.on('list:created', (msg) => {
dispatch({
type: Constants.CURRENT_BOARD_LIST_CREATED,
list: msg.list,
});
});
};
},
// ...
}
Наконец, нам нужно обновить преобразователь (reducer) доски, чтобы добавить список к новой версии состояния, которую он возвращает:
// web/static/js/reducers/current_board.js
import Constants from '../constants';
export default function reducer(state = initialState, action = {}) {
switch (action.type) {
//...
case Constants.CURRENT_BOARD_LIST_CREATED:
const lists = [...state.lists];
lists.push(action.list);
return { ...state, lists: lists, showForm: false };
// ...
}
}
Нам так же нужно установить аттрибут showForm
в false
, чтобы автоматически скрыть форму и вновь показать кнопку Добавить новый список... вместе с только что созданным списком:

Компонент List
Теперь на доске есть как минимум один список, и мы можем создать компонент List
, которым воспользуемся для отрисовки:
// /web/static/js/components/lists/card.js
import React, {PropTypes} from 'react';
import Actions from '../../actions/current_board';
import CardForm from '../../components/cards/form';
import Card from '../../components/cards/card';
export default class ListCard extends React.Component {
// ...
_renderForm() {
const { isAddingNewCard } = this.props;
if (!isAddingNewCard) return false;
let { id, dispatch, formErrors, channel } = this.props;
return (
<CardForm
listId={id}
dispatch={dispatch}
errors={formErrors}
channel={channel}
onCancelClick={::this._hideCardForm}
onSubmit={::this._hideCardForm}/>
);
}
_renderAddNewCard() {
const { isAddingNewCard } = this.props;
if (isAddingNewCard) return false;
return (
<a className="add-new" href="#" onClick={::this._handleAddClick}>Add a new card...</a>
);
}
_handleAddClick(e) {
e.preventDefault();
const { dispatch, id } = this.props;
dispatch(Actions.showCardForm(id));
}
_hideCardForm() {
const { dispatch } = this.props;
dispatch(Actions.showCardForm(null));
}
render() {
const { id, connectDragSource, connectDropTarget, connectCardDropTarget, isDragging } = this.props;
const styles = {
display: isDragging ? 'none' : 'block',
};
return (
<div id={`list_${id}`} className="list" style={styles}>
<div className="inner">
<header>
<h4>{this.props.name}</h4>
</header>
<div className="cards-wrapper">
{::this._renderCards()}
</div>
<footer>
{::this._renderForm()}
{::this._renderAddNewCard()}
</footer>
</div>
</div>
);
}
}
Точно так же, как и в случае списков, сначала сосредоточимся на отрисовке формы карточек. В целом мы воспользуемся тем же подходом к отрисовке или скрытию формы, используя свойство (prop
), передаваемое основным компонентом доски, и направляя действие для изменения этого свойства состояния.

Компонент формы карточки
Этот компонент будет очень похож на компонент ListForm
:
// /web/static/js/components/cards/form.js
import React, { PropTypes } from 'react';
import Actions from '../../actions/lists';
import PageClick from 'react-page-click';
export default class CardForm extends React.Component {
_handleSubmit(e) {
e.preventDefault();
let { dispatch, channel } = this.props;
let { name } = this.refs;
let data = {
list_id: this.props.listId,
name: name.value,
};
dispatch(Actions.createCard(channel, data));
this.props.onSubmit();
}
componentDidMount() {
this.refs.name.focus();
}
_handleCancelClick(e) {
e.preventDefault();
this.props.onCancelClick();
}
render() {
return (
<PageClick onClick={::this._handleCancelClick}>
<div className="card form">
<form id="new_card_form" onSubmit={::this._handleSubmit}>
<textarea ref="name" id="card_name" type="text" required="true" rows={5}/>
<button type="submit">Add</button> or <a href="#" onClick={::this._handleCancelClick}>cancel</a>
</form>
</div>
</PageClick>
);
}
}
Так же, как и ранее, при отправке формы мы направляем действие для создания карточки с именем, предоставленным пользователем. Для этого конструктор действия направит новое сообщение в канал:
// /web/static/js/actions/lists.js
import Constants from '../constants';
const Actions = {
// ...
createCard: (channel, data) => {
return dispatch => {
channel.push('cards:create', { card: data });
};
},
};
// ...
Давайте добавим обработчик к BoardChannel
:
# web/channels/board_channel.ex
def handle_in("cards:create", %{"card" => card_params}, socket) do
board = socket.assigns.board
changeset = board
|> assoc(:lists)
|> Repo.get!(card_params["list_id"])
|> build_assoc(:cards)
|> Card.changeset(card_params)
case Repo.insert(changeset) do
{:ok, card} ->
broadcast! socket, "card:created", %{card: card}
{:noreply, socket}
{:error, _changeset} ->
{:reply, {:error, %{error: "Error creating card"}}, socket}
end
end
Тем же способом, что создавая список, новая запись Card
будет создана ассоциированием с доской, прикреплённой к каналу, и со списком, передаваемым в качестве параметра. Если создание было успешно, запись будет переправлена всем подключенным к каналу участникам. Наконец, нужно добавить callback-функцию к js-каналу:
// web/static/js/actions/current_board.js
//...
channel.on('card:created', (msg) => {
dispatch({
type: Constants.CURRENT_BOARD_CARD_CREATED,
card: msg.card,
});
});
// ...
И добавим новую карточку к состоянию через преобразователь:
// web/static/js/reducers/current_board.js
// ...
case Constants.CURRENT_BOARD_CARD_CREATED:
lists = [...state.lists];
const { card } = action;
const listIndex = lists.findIndex((list) => { return list.id == card.list_id; });
lists[listIndex].cards.push(card);
return { ...state, lists: lists };
// ...
И это всё! Карточка будет появляться на экране каждого подключенного участника.

Что теперь?
Этим разделом мы завершили создание базовой функциональности, требуемой для регистрации пользователя, входа в систему, создания досок, приглашения на них других людей и совместной работы в реальном времени путём добавления списков и карточек. Окончательная версия в репозитории имеет значительно больше возможностей, таких, как редактирование списков, сортировка списков и карточек их перемещением, отображением более детальной информации о карточках, где вы так же можете назначить им участников и даже добавить комментарии и цветные метки, но мы не будем подробно говорить ни об одной из них, иначе это стало бы вечным тренингом. :-D
Но не волнуйтесь, осталась ещё одна часть, где мы поговорим о том, как поделиться результатом со всем миром, выложив его на Heroku.
Выкладываем проект на Heroku
Мы наконец-то сделали это (и я тоже — прим. переводчика). После 5 (в оригинале — 11 — прим. переводчика) публикаций мы узнали, как настроить новый проект Phoenix с Webpack, React и Redux. Мы создали безопасную систему аутентификации, основанную на JWT-токенах, создали миграции для требуемых нам схем нашей базы данных, запрограммировали сокеты и каналы для функциональности реального времени и построили процесс GenServer, чтобы отслеживать подключённых участников досок. Настало время поделиться всем этим с миром, выложив проект на Heroku. Давайте сделаем это!
Настраиваем Heroku
Прежде, чем двигаться дальше, предположим, что у нас уже есть аккаунт Heroku и установленный Heroku Toolbelt. Чтобы выложить на Heroku приложение Phoenix, нам понадобится использовать два различных buildpacks (набора для сборки), так что создадим новое приложение, используя multi-buildpack:
$ heroku create phoenix-trello --buildpack https://github.com/ddollar/heroku-buildpack-multi
Это создаст новое приложение на Heroku и добавит удалённый git-репозиторий heroku
, которым мы воспользуемся для публикации. Как я сказал ранее, для приложения Phoenix нам потребуется два различных пакета для сборки:
- heroku-buildpack-elixir: Главный набор для сборки приложений Elixir.
- heroku-buildpack-phoenix-static: Для компиляции статических файлов.
Создадим файл .buildpacks
и добавим оба набора:
# .buildpacks
https://github.com/HashNuke/heroku-buildpack-elixir
https://github.com/gjaldon/phoenix-static-buildpack
Если нам нужно изменить любой аспект, относящийся к рабочему окружению Elixir, мы можем сделать это, добавив файл elixir_buildpack.config
:
# elixir_buildpack.config
# Elixir version
elixir_version=1.2.3
# Always rebuild from scratch on every deploy?
always_rebuild=true
В нашем случае мы указываем версию Elixir, а так же требуем от окружения пересобирать всё, включая зависимости, при каждой публикации. То же самое может быть сделано и для статики в файле phoenix_static_buildpack.config
:
# phoenix_static_buildpack.config
# We can set the version of Node to use for the app here
node_version=5.3.0
# We can set the version of NPM to use for the app here
npm_version=3.5.2
В данном случае мы указываем требуемые для Webpack версии node
и npm
. В конце концов мы должны создать файл compile
, в котором мы укажем, как компилировать наши ресурсы после очередной публикации:
# compile
info "Building Phoenix static assets"
webpack
mix phoenix.digest
Обратите внимание, что мы запускаем mix-задачу phoenix.digest
после сборки webpack
, чтобы сгенерировать переработанные и сжатые версии ресурсов.
Настройка рабочего окружения
Прежде, чем сделать первую публикацию, необходимо обновить файл prod.exs
, сделав требуемые изменения конфигурации:
# config/prod.exs
use Mix.Config
# ...
config :phoenix_trello, PhoenixTrello.Endpoint,
# ..
url: [scheme: "https", host: "phoenix-trello.herokuapp.com", port: 443],
# ..
secret_key_base: System.get_env("SECRET_KEY_BASE")
# ..
# Configure your database
config :phoenix_trello, PhoenixTrello.Repo,
# ..
url: System.get_env("DATABASE_URL"),
pool_size: 20
# Configure guardian
config :guardian, Guardian,
secret_key: System.get_env("GUARDIAN_SECRET_KEY")
Главное, что мы тут делаем: заставляем использовать URL нашего приложения Heroku и SSL-соединение. Мы так же используем некоторые переменные окружения, чтобы сконфигурировать secret_key_base
, ссылку на базу данных (url
) и secret_key
для guardian. Ссылка на базу данных будет создана Heroku автоматически при первой публикации, но остальные две переменные нам нужно сгенерировать самостоятельно и добавить их, используя командную строку:
$ mix phoenix.gen.secret
xxxxxxxxxx
$ heroku config:set SECRET_KEY_BASE="xxxxxxxxxx"
...
...
$ mix phoenix.gen.secret
yyyyyyyyyyy
$ heroku config:set GUARDIAN_SECRET_KEY="yyyyyyyyyyy"
...
...
И мы готовы к публикации!
Публикация
После передачи всех изменений в наш репозиторий мы можем опубликовать приложение просто запустив:
$ git push heroku master
...
...
...
Если мы взглянем на вывод консоли, то увидим, как оба набора для сборки делают свою работу, устанавливая Erlang и Elixir с требуемыми им зависимостями, равно как и node, npm и прочие вещи. Наконец, нам нужно запустить миграцию для создания таблиц базы данных:
$ heroku run mix ecto.migrate
И на этом всё, наше приложение опубликовано и готово к работе!
Заключение
Публикация приложения Phoenix на Heroku допольно проста и понятна. Это может оказаться не лучшим решением, но для демонстрационного приложения вроде нашего работает неплохо. Надеюсь, вы наслаждались созданием и публикацией этого приложения настолько же, насколько и я. Во время написания всего цикла я произвёл море изменений в окончательный код, внося исправления и добавив ещё больше возможностей. Если хотите на них посмотреть, не забудьте посетить живое демо и форкнуть исходный код конечного результата.
P.S. Как всегда, прошу сообщать об опечатках любым способом. Если у вас есть лучшая формулировка какой-нибудь фразы — не поленитесь мне написать, хоть в личку, хоть в комментарии, обязательно обновлю. Ну, и ответ на вопрос о том, стоит ли продолжать такие публикации, даст рейтинг материала.