Создание движка для блога с помощью Phoenix и Elixir / Часть 9. Каналы

Original author: Brandon Richey
  • Translation
  • Tutorial


От переводчика: «Elixir и Phoenix — прекрасный пример того, куда движется современная веб-разработка. Уже сейчас эти инструменты предоставляют качественный доступ к технологиям реального времени для веб-приложений. Сайты с повышенной интерактивностью, многопользовательские браузерные игры, микросервисы — те направления, в которых данные технологии сослужат хорошую службу. Далее представлен перевод серии из 11 статей, подробно описывающих аспекты разработки на фреймворке Феникс казалось бы такой тривиальной вещи, как блоговый движок. Но не спешите кукситься, будет действительно интересно, особенно если статьи побудят вас обратить внимание на Эликсир либо стать его последователями.

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


На данный момент наше приложение основано на:


  • Elixir: v1.3.1
  • Phoenix: v1.2.0
  • Ecto: v2.0.2

На чём мы остановились


В прошлый раз было полностью покончено с комментариями! Теперь, когда все функции готовы, давайте сделаем блог по-настоящему классным, воспользовавшись возможностями, которые Elixir и Phoenix предоставляют прямо из коробки. Превратим систему комментариев в систему «живых» комментариев с помощью каналов из Phoenix. Честно признаёмся: в этой части ОЧЕНЬ много тяжёлого джаваскрипта.


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


  1. Новые комментарии по мере появления должны транслироваться только авторизованным пользователям.
  2. При одобрении, комментарии должны транслироваться всем пользователям.
  3. Удалённые комментарии должны пропадать у всех пользователей.

Добавляем каналы


Первым шагом при реализации любого канала в Phoenix является работа с файлом web/channels/user_socket.ex. Изменим закомментированную строку, находящуюся под ## Channels, на следующее:


channel "comments:*", Pxblog.CommentChannel

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


$ mix phoenix.gen.channel Comment

* creating web/channels/comment_channel.ex
* creating test/channels/comment_channel_test.exs

Add the channel to your `web/channels/user_socket.ex` handler, for example:
channel "comment:lobby", Pxblog.CommentChannel

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


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


Для авторизованных пользователей:


  1. Комментарий создан
  2. Комментарий удалён
  3. Комментарий одобрен

Для всех:


  1. Комментарий одобрен
  2. Комментарий удалён

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


Добавление jQuery с помощью Brunch


Начнём с установки jQuery через NPM.


npm install --save-dev jquery

А затем перезагрузим сервер Phoenix и проверим, что jQuery успешно установился. Откройте файл web/static/js/app.js и добавьте вниз следующий код:


import $ from "jquery"

if ($("body")) {
 console.log("jquery works!")
}

Если вы увидели сообщение «jquery works!» в Developer Console браузера, то можете удалять эти строчки и переходить к следующему шагу.


Реализация каналов на Javascript


Первым делом вернёмся к файлу web/static/js/app.js и раскомментируем оператор импорта сокета.


Затем откроем файл web/static/js/socket.js и внесём несколько небольших правок:


// For right now, just hardcode this to whatever post id you're working with

const postId = 2;
const channel = socket.channel(`comments:${postId}`, {});

channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) });

Обратимся к описанию сокета, чтобы понять какие сообщения нужно слушать/транслировать. Будем использовать «CREATED_COMMENT» для свежесозданных комментариев, «APPROVED_COMMENT» для одобренных комментариев и «DELETED_COMMENT» для удалённых. Добавим их в качестве констант в файл socket.js:


const CREATED_COMMENT  = "CREATED_COMMENT"
const APPROVED_COMMENT = "APPROVED_COMMENT"
const DELETED_COMMENT  = "DELETED_COMMENT"

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


channel.on(CREATED_COMMENT, (payload) => {
  console.log("Created comment", payload)
});
channel.on(APPROVED_COMMENT, (payload) => {
  console.log("Approved comment", payload)
});
channel.on(DELETED_COMMENT, (payload) => {
  console.log("Deleted comment", payload)
});

И наконец, изменим кнопку Submit, чтобы вместо отправки комментария создавать «фальшивое» событие:


$("input[type=submit]").on("click", (event) => {
  event.preventDefault()
  channel.push(CREATED_COMMENT, { author: "test", body: "body" })
})

Доработка кода для поддержки каналов


Если попробовать протестировать в браузере, приложение упадёт. Вы получите сообщение об ошибке наподобие этого:


[error] GenServer #PID<0.1250.0> terminating
** (FunctionClauseError) no function clause matching in Pxblog.CommentChannel.handle_in/3
 (pxblog) web/channels/comment_channel.ex:14: Pxblog.CommentChannel.handle_in(“CREATED_COMMENT”, %{“author” => “test”, “body” => “body”}, %Phoenix.Socket{assigns: %{}, channel: Pxblog.CommentChannel, channel_pid: #PID<0.1250.0>, endpoint: Pxblog.Endpoint, handler: Pxblog.UserSocket, id: nil, joined: true, pubsub_server: Pxblog.PubSub, ref: “2”, serializer: Phoenix.Transports.WebSocketSerializer, topic: “comments:2”, transport: Phoenix.Transports.WebSocket, transport_name: :websocket, transport_pid: #PID<0.1247.0>})
 (phoenix) lib/phoenix/channel/server.ex:229: Phoenix.Channel.Server.handle_info/2
 (stdlib) gen_server.erl:615: :gen_server.try_dispatch/4
 (stdlib) gen_server.erl:681: :gen_server.handle_msg/5
 (stdlib) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
Last message: %Phoenix.Socket.Message{event: “CREATED_COMMENT”, payload: %{“author” => “test”, “body” => “body”}, ref: “2”, topic: “comments:2”}
State: %Phoenix.Socket{assigns: %{}, channel: Pxblog.CommentChannel, channel_pid: #PID<0.1250.0>, endpoint: Pxblog.Endpoint, handler: Pxblog.UserSocket, id: nil, joined: true, pubsub_server: Pxblog.PubSub, ref: nil, serializer: Phoenix.Transports.WebSocketSerializer, topic: “comments:2”, transport: Phoenix.Transports.WebSocket, transport_name: :websocket, transport_pid: #PID<0.1247.0>}

Прямо сейчас у нас нет функциии для обработки сообщений внутри канала. Откройте файл web/channels/comment_channel.ex и давайте сделаем так, чтобы функция handle_in транслировала сообщения подписчикам, вместо того, чтобы молча наблюдать. Нам также нужно изменить стандартную функцию join вверху:


def join("comments:" <> _comment_id, payload, socket) do
  if authorized?(payload) do
    {:ok, socket}
  else
    {:error, %{reason: "unauthorized"}}
  end
end

# ...

# It is also common to receive messages from the client and
# broadcast to everyone in the current topic (comments:lobby).
def handle_in("CREATED_COMMENT", payload, socket) do
  broadcast socket, "CREATED_COMMENT", payload
  {:noreply, socket}
end

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


def handle_in("APPROVED_COMMENT", payload, socket) do
  broadcast socket, "APPROVED_COMMENT", payload
  {:noreply, socket}
end

def handle_in("DELETED_COMMENT", payload, socket) do
  broadcast socket, "DELETED_COMMENT", payload
  {:noreply, socket}
end

Нам также нужно внести несколько правок в шаблоны. Необходимо знать с каким постом мы работаем и кем является текущий пользователь. Таким образом, добавим наверх файла web/templates/post/show.html.eex следующий код:


<input type="hidden" id="post-id" value="<%= @post.id %>">

Затем откройте файл web/templates/comment/comment.html.eex и измените открывающий div:


<div id="comment-<%= @comment.id %>" class="comment" data-comment-id="<%= @comment.id %>">

Теперь, когда всё, связанное с комментариями, обрабатывается через Javascript, нам нужно удалить некоторый написанный ранее код для кнопок Approve/Reject. Изменим весь блок, чтобы он выглядел похожим на это:


<%= if @conn.assigns[:author_or_admin] do %>
  <%= unless @comment.approved do %>
    <button class="btn btn-xs btn-primary approve">Approve</button>
  <% end %>
  <button class="btn btn-xs btn-danger delete">Delete</button>
<% end %>

Также, внутри тегов div, где выводятся автор и текст комментария, измените тег strong, чтобы у них появились классы .comment-author и .comment-body, соответственно.


<div class="col-xs-4">
  <strong class="comment-author"><%= @comment.author %></strong>
</div>

...


<div class="col-xs-12 comment-body">
  <%= @comment.body %>
</div>

Наконец, нужно убедиться, что мы можем подходящим образом обращаться к автору и тексту комментария, так что откроем файл web/templates/comment/form.html.eex и убедимся, что поле ввода комментария и кнопка отправки выглядят так:


<div class="form-group">
  <%= label f, :body, class: "control-label" %>
  <%= textarea f, :body, class: "form-control" %>
  <%= error_tag f, :body %>
</div>

<div class="form-group">
  <%= submit "Submit", class: "btn btn-primary create-comment" %>
</div>

Сейчас необходимо реализовать каждую из возможностей транслирования должным образом, так что вернёмся на «поле джаваскрипта» и продолжим!


Реализация идентификатора пользователя через Phoenix.Token


Нам понадобится способ для проверки, является ли пользователь тем, за кого себя выдаёт и имеет ли он доступ к изменению данных комментария. Чтобы сделать это, мы воспользуемся встроенным в Phoenix модулем Phoenix.Token.


Начнём с проставления пользовательского токена в шаблон приложения. Это достаточно удобно, ведь вероятно мы захотим отображать его везде. В файле web/templates/layout/app.html.eex добавьте следующее к остальным мета-тегам:


<%= if user = current_user(@conn) do %>
  <%= tag :meta, name: "channel_token", content: Phoenix.Token.sign(@conn, "user", user.id) %>
<% end %>

Здесь мы говорим, что хотим подписанный токен, который указывает на идентификатор пользователя (конечно, если пользователь вошёл в систему). Это даст нам прекрасный способ для проверки user_id пользователя через Javascript без необходимости доверять скрытым полям ввода или пользоваться другими странными способами.


Далее в файле web/static/js/socket.js, внесите несколько изменений в код соединения сокета:


// Grab the user's token from the meta tag
const userToken = $("meta[name='channel_token']").attr("content")
// And make sure we're connecting with the user's token to persist the user id to the session
const socket = new Socket("/socket", {params: {token: userToken}})
// And then connect to our socket
socket.connect()

Теперь передадим валидный токен обратно в код Phoenix. На этот раз нам нужен файл web/channels/user_socket.ex, в котором изменим функцию connect для проверки токена пользователя:


def connect(%{"token" => token}, socket) do
  case Phoenix.Token.verify(socket, "user", token, max_age: 1209600) do
    {:ok, user_id} ->
      {:ok, assign(socket, :user, user_id)}
    {:error, reason} ->
      {:ok, socket}
  end
end

Итак, мы вызываем функцию verify из модуля Phoenix.Token и передаём в неё сокет, значение для проверки, сам токен и значение max_age (максимальное время жизни токена, например, две недели).


Если верификация прошла успешно, то отправим обратно кортеж {:ok, [значение, извлечённое из токена]}, которым в нашем случае является user_id. Затем поддержим соединение со значением user_id, сохранённым в сокете (подобно сохранению значения в сессии или conn).


Если верифицировать соединение не удалось, это тоже нормально. Так как мы по-прежнему хотим, чтобы неавторизованные пользователи так же могли получать обновления без верифицированного user_id, то не будем ничего присваивать, а просто вернём {:ok, socket}.


Возвращаемся к Socket.js


Нам понадобится тонна Javascript-кода для поддержки всего задуманного. Рассмотрим задачи подробнее:


  1. Брать значение postId прямиком из DOM
  2. Написать функцию для генерации шаблона нового комментария
  3. Написать функцию для получения автора комментария
  4. Написать функцию для получения текста комментария
  5. Написать функцию для получения идентификатора комментария
  6. Написать функцию для сброса комментария
  7. Написать функцию для обработки создания комментария
  8. Написать функцию для обработки одобрения комментария
  9. Написать функцию для обработки удаления комментария
  10. Написать функцию для обработки события создания комментария
  11. Написать функцию для обработки события одобрения комментария
  12. Написать функцию для обработки события удаления комментария

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


// Import the socket library
import {Socket} from "phoenix"
// And import jquery for DOM manipulation
import $ from "jquery"

// Grab the user's token from the meta tag
const userToken = $("meta[name='channel_token']").attr("content")
// And make sure we're connecting with the user's token to persist the user id to the session
const socket = new Socket("/socket", {params: {token: userToken}})
// And connect out
socket.connect()

// Our actions to listen for
const CREATED_COMMENT  = "CREATED_COMMENT"
const APPROVED_COMMENT = "APPROVED_COMMENT"
const DELETED_COMMENT  = "DELETED_COMMENT"

// REQ 1: Grab the current post's id from a hidden input on the page
const postId = $("#post-id").val()
const channel = socket.channel(`comments:${postId}`, {})
channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

// REQ 2: Based on a payload, return to us an HTML template for a comment
// Consider this a poor version of JSX
const createComment = (payload) => `
  <div id="comment-${payload.commentId}" class="comment" data-comment-id="${payload.commentId}">
    <div class="row">
      <div class="col-xs-4">
        <strong class="comment-author">${payload.author}</strong>
      </div>
      <div class="col-xs-4">
        <em>${payload.insertedAt}</em>
      </div>
      <div class="col-xs-4 text-right">
        ${ userToken ? '<button class="btn btn-xs btn-primary approve">Approve</button> <button class="btn btn-xs btn-danger delete">Delete</button>' : '' }
      </div>
    </div>
    <div class="row">
      <div class="col-xs-12 comment-body">
        ${payload.body}
      </div>
    </div>
  </div>
`
// REQ 3: Provide the comment's author from the form
const getCommentAuthor   = () => $("#comment_author").val()
// REQ 4: Provide the comment's body from the form
const getCommentBody     = () => $("#comment_body").val()
// REQ 5: Based on something being clicked, find the parent comment id
const getTargetCommentId = (target) => $(target).parents(".comment").data("comment-id")
// REQ 6: Reset the input fields to blank
const resetFields = () => {
  $("#comment_author").val("")
  $("#comment_body").val("")
}

// REQ 7: Push the CREATED_COMMENT event to the socket with the appropriate author/body
$(".create-comment").on("click", (event) => {
  event.preventDefault()
  channel.push(CREATED_COMMENT, { author: getCommentAuthor(), body: getCommentBody(), postId })
  resetFields()
})

// REQ 8: Push the APPROVED_COMMENT event to the socket with the appropriate author/body/comment id
$(".comments").on("click", ".approve", (event) => {
  event.preventDefault()
  const commentId = getTargetCommentId(event.currentTarget)
  // Pull the approved comment author
  const author = $(`#comment-${commentId} .comment-author`).text().trim()
  // Pull the approved comment body
  const body = $(`#comment-${commentId} .comment-body`).text().trim()
  channel.push(APPROVED_COMMENT, { author, body, commentId, postId })
})

// REQ 9: Push the DELETED_COMMENT event to the socket but only pass the comment id (that's all we need)
$(".comments").on("click", ".delete", (event) => {
  event.preventDefault()
  const commentId = getTargetCommentId(event.currentTarget)
  channel.push(DELETED_COMMENT, { commentId, postId })
})

// REQ 10: Handle receiving the CREATED_COMMENT event
channel.on(CREATED_COMMENT, (payload) => {
  // Don't append the comment if it hasn't been approved
  if (!userToken && !payload.approved) { return; }
  // Add it to the DOM using our handy template function
  $(".comments h2").after(
    createComment(payload)
  )
})

// REQ 11: Handle receiving the APPROVED_COMMENT event
channel.on(APPROVED_COMMENT, (payload) => {
  // If we don't already have the right comment, then add it to the DOM
  if ($(`#comment-${payload.commentId}`).length === 0) {
    $(".comments h2").after(
      createComment(payload)
    )
  }
  // And then remove the "Approve" button since we know it has been approved
  $(`#comment-${payload.commentId} .approve`).remove()
})

// REQ 12: Handle receiving the DELETED_COMMENT event
channel.on(DELETED_COMMENT, (payload) => {
  // Just delete the comment from the DOM
  $(`#comment-${payload.commentId}`).remove()
})

export default socket

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


Вспомогательные функции для каналов


С нашим Javascript-кодом всё отлично, так что, вероятно, добавить вспомогательные функции нужно на стороне бэкенда. Начнём с создания нового модуля, который станет рабочей лошадкой наших взаимодействий с базой данных для создания/одобрения/удаления комментариев. Так что создадим файл web/channels/comment_helper.ex:


defmodule Pxblog.CommentHelper do
  alias Pxblog.Comment
  alias Pxblog.Post
  alias Pxblog.User
  alias Pxblog.Repo

  import Ecto, only: [build_assoc: 2]

  def create(%{"postId" => post_id, "body" => body, "author" => author}, _socket) do
    post      = get_post(post_id)
    changeset = post
      |> build_assoc(:comments)
      |> Comment.changeset(%{body: body, author: author})

    Repo.insert(changeset)
  end

  def approve(%{"postId" => post_id, "commentId" => comment_id}, %{assigns: %{user: user_id}}) do
    authorize_and_perform(post_id, user_id, fn ->
      comment = Repo.get!(Comment, comment_id)
      changeset = Comment.changeset(comment, %{approved: true})
      Repo.update(changeset)
    end)
  end

  def delete(%{"postId" => post_id, "commentId" => comment_id}, %{assigns: %{user: user_id}}) do
    authorize_and_perform(post_id, user_id, fn ->
      comment = Repo.get!(Comment, comment_id)
      Repo.delete(comment)
    end)
  end

  defp authorize_and_perform(post_id, user_id, action) do
    post = get_post(post_id)
    user = get_user(user_id)
    if is_authorized_user?(user, post) do
      action.()
    else
      {:error, "User is not authorized"}
    end
  end

  defp get_user(user_id) do
    Repo.get!(User, user_id)
  end

  defp get_post(post_id) do
    Repo.get!(Post, post_id) |> Repo.preload([:user, :comments])
  end

  defp is_authorized_user?(user, post) do
    (user && (user.id == post.user_id || Pxblog.RoleChecker.is_admin?(user)))
  end
end

Начнём сверху. Мы будем часто обращаться к модулям Comment/Post/User/Repo, так что для чистоты кода, правильно будет добавить для них алиасы. Нам также нужно импортировать функцию build_assoc из Ecto, но только версию арности 2.


Далее мы переходим сразу к созданию поста. Мы по привычке передаём в функцию socket, но он нам не всегда нужнен. Например, в данном случае, ведь комментарий добавить может любой. Мы сопоставляем с образцом значения post_id, body и author в аргументах, чтобы с ними можно было работать внутри функции.


def create(%{"postId" => post_id, "body" => body, "author" => author}, _socket) do
  post      = get_post(post_id)
  changeset = post
    |> build_assoc(:comments)
    |> Comment.changeset(%{body: body, author: author})

  Repo.insert(changeset)
end

Мы получаем пост через функцию get_post , которую пока не написали. Это будет приватная функция чуть ниже. Затем создаём ченджсет из поста для создания связанного комментария. В конце вернём результат функции Repo.insert. Это абсолютно простой и стандарный код на Ecto, так что здесь никаких сюрпризов возникнуть не должно. Тоже самое можно сказать и обо всех остальных функциях. Далее взглянем на функцию approve:


def approve(%{"postId" => post_id, "commentId" => comment_id}, %{assigns: %{user: user_id}}) do
  authorize_and_perform(post_id, user_id, fn ->
    comment = Repo.get!(Comment, comment_id)
    changeset = Comment.changeset(comment, %{approved: true})
    Repo.update(changeset)
  end)
end

Здесь снова сопоставляются с образцом необходимые значения — post_id и comment_id из первого аргумента и проверенный user_id из сокета, переданного вторым. Далее мы вызываем вспомогательную функцию authorize_and_perform и передаём в неё анонимную функцию, которая получает комментарий, обновляет флаг approved в true через ченджсет и затем отправляет обновление в Repo. Довольно стандартный код, но эта функция authorize_and_perform выглядит загадочной, так что давайте отвлечёмся на её разбор:


defp authorize_and_perform(post_id, user_id, action) do
  post = get_post(post_id)
  user = get_user(user_id)
  if is_authorized_user?(user, post) do
    action.()
  else
    {:error, "User is not authorized"}
  end
end

В неё передаются post_id и user_id, так как оба этих значения требуются для правильной авторизации действий с комментарием. Затем вызывается другая вспомогательная функция is_authorized_user?, которая, получая пользователя и пост, возвращает true или false. Если всё хорошо, то вызывается анонимная функция action. Обратите внимание на точку между названием и скобками. Иначе возвращается кортеж {:error, «User is not authorized»}, который мы можем перехватить далее, если захотим вывести красивое сообщение об ошибке.


Авторизация совершается внутри функции, а затем исполняются действия, переданные с помощью блока fn -> ... end. Это хороший образец, когда дублируется много логики.


С функции authorize_and_perform достаточно. Перейдём к функции delete:


def delete(%{"postId" => post_id, "commentId" => comment_id}, %{assigns: %{user: user_id}}) do
  authorize_and_perform(post_id, user_id, fn ->
    comment = Repo.get!(Comment, comment_id)
    Repo.delete(comment)
  end)
end

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


Наконец, взглянем на вспомогательные функции поменьше.


defp get_user(user_id) do
  Repo.get!(User, user_id)
end

defp get_post(post_id) do
  Repo.get!(Post, post_id) |> Repo.preload([:user, :comments])
end

defp is_authorized_user?(user, post) do
  (user && (user.id == post.user_id || Pxblog.RoleChecker.is_admin?(user)))
end

Получение пользователя, получение поста и проверка авторизации пользователя (код, взятый из PostController). Со вспомогательными функциями покончено. Добавим их в CommentChannel.


Встраивание вспомогательных функций в канал комментариев


Все, что нам нужно — заменить первоначальный код с сообщениями CREATED/APPROVED/DELETED на вспомогательные функции. Откройте файл web/channels/comment_channel.ex:


alias Pxblog.CommentHelper
  # It is also common to receive messages from the client and
  # broadcast to everyone in the current topic (comments:lobby).
  def handle_in("CREATED_COMMENT", payload, socket) do
    case CommentHelper.create(payload, socket) do
      {:ok, comment} ->
        broadcast socket, "CREATED_COMMENT", Map.merge(payload, %{insertedAt: comment.inserted_at, commentId: comment.id, approved: comment.approved})
        {:noreply, socket}
      {:error, _} ->
        {:noreply, socket}
    end
  end

  def handle_in("APPROVED_COMMENT", payload, socket) do
    case CommentHelper.approve(payload, socket) do
      {:ok, comment} ->
        broadcast socket, "APPROVED_COMMENT", Map.merge(payload, %{insertedAt: comment.inserted_at, commentId: comment.id})
        {:noreply, socket}
      {:error, _} ->
        {:noreply, socket}
    end
  end

  def handle_in("DELETED_COMMENT", payload, socket) do
    case CommentHelper.delete(payload, socket) do
      {:ok, _} ->
        broadcast socket, "DELETED_COMMENT", payload
        {:noreply, socket}
      {:error, _} ->
        {:noreply, socket}
    end
  end

Принципы схожи во всех трёх вызовах, так что разберём только create:


# It is also common to receive messages from the client and
  # broadcast to everyone in the current topic (comments:lobby).
  def handle_in("CREATED_COMMENT", payload, socket) do
    case CommentHelper.create(payload, socket) do
      {:ok, comment} ->
        broadcast socket, "CREATED_COMMENT", Map.merge(payload, %{insertedAt: comment.inserted_at, commentId: comment.id, approved: comment.approved})
        {:noreply, socket}
      {:error, _} ->
        {:noreply, socket}
    end
  end

Сигнатура функции не изменилась, так что оставим её в покое. Первое, что мы делаем — добавляем оператор case для функции CommentHelper.create и передаём в неё payload и socket (вспомните про сопоставление с образцом, которое мы делали). Если приходит :ok вместе с созданным комментарием, мы транслируем в сокет сообщение CREATED_COMMENT вместе с некоторыми данными из базы, которых нет у Javascript. Если произошла ошибка, то не нужно ничего транслировать, просто возвращаем сокет и беззаботно идём дальше.



Заключение


Теперь у блога есть потрясающая система комментариев в реальном времени. Также мы очень глубоко нырнули в изучение каналов и токенов в Phoenix И узнали как совместить их для повышения уровня безопасности. В этом плане пример, конечно, не идеален — мы по-прежнему транслируем все комментарии, но не все добавляем в div. Так что кто-то может увидеть все добавленные комментарии, даже неодобренные. Дальше это можно улучшить с помощью создания отдельных аутентифицированных и неаутентифицированных каналов и транлировать сообщения только в необходимый. Учитывая, что фильтрация комментариев лишь помогает избежать спам, то повышение защищённости в этом случае не так важно. Но этот приём может пригодиться в будущем. Также мы забыли про тесты. Лучше стараться так не делать, но эта часть получается больно длинной, так что закончим с тестами в следующей. Это позволит оставить уровень покрытия кода высоким и удалить некоторый ненужный код.


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


Другие статьи серии


  1. Вступление
  2. Авторизация
  3. Добавляем роли
  4. Обрабатываем роли в контроллерах
  5. Подключаем ExMachina
  6. Поддержка Markdown
  7. Добавляем комментарии
  8. Заканчиваем с комментариями
  9. Каналы
  10. Тестирование каналов
  11. Заключение

Скромное заключение от переводчика


Ребята! Ещё три десятка статей по Elixir на русском ждут вас на сайте нашего проекта под названием Вунш. Там же можно подписаться на прикольную рассылку и получать самые интересные новости по Эликсиру вместе с эксклюзивными статьями. Приглашаю вас также присоединиться к работе над переводом документации. А если появились вопросы, то подключайтесь к нашему чату и каналу в Телеграм.

Share post

Comments 0

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