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

Original author: Brandon Richey
  • Translation
  • Tutorial


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

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

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

Установка Phoenix


Лучшая инструкция по установке Phoenix находится на его официальном сайте.

Шаг 1. Добавление постов


Начнём с запуска mix-задачи для создания нового проекта под названием «pxblog». Для этого выполним команду `mix phoenix.new [project] [command]`. Отвечаем утвердительно на все вопросы, так как нам подойдут настройки по умолчанию.

mix phoenix.new pxblog

Вывод:

* creating pxblog/config/config.exs
...
Fetch and install dependencies? [Yn] y
* running mix deps.get
* running npm install && node node_modules/brunch/bin/brunch build

We are all set! Run your Phoenix application:
$ cd pxblog
$ mix phoenix.server

You can also run your app inside IEx (Interactive Elixir) as:
$ iex -S mix phoenix.server

Before moving on, configure your database in config/dev.exs and run:
$ mix ecto.create

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

Если у вас не создана база данных Postgres или приложение не настроено на работу с ней, команда `mix ecto.create` выкинет ошибку. Для её исправления откройте файл config/dev.exs и просто измените имя пользователя и пароль для роли, которая имеет права на создание базы данных:

# Configure your database
config :pxblog, Pxblog.Repo,
  adapter: Ecto.Adapters.Postgres,
  username: "postgres",
  password: "postgres",
  database: "pxblog_dev",
  hostname: "localhost",
  pool_size: 10

Когда всё заработает, давайте запустим сервер и убедимся, что всё хорошо.

$ iex -S mix phoenix.server

Для этого перейдём по адресу http://localhost:4000/ и увидим страницу Welcome to Phoenix!. Хорошая основа готова. Давайте добавим к ней главный скаффолд для работы с постами, так как всё же у нас блоговый движок.

Воспользуемся встроенным в Phoenix генератором для создания Ecto-модели, миграции и интерфейса обработки CRUD-операций модуля Post. Так как на данный момент это очень и очень простой движок, то ограничимся заголовком и сообщением. Заголовок будет строкой, а сообщение — текстом. Команда, которая создаст это всё за нас довольно простая:

$ mix phoenix.gen.html Post posts title:string body:text

Вывод:

* creating web/controllers/post_controller.ex
...
Add the resource to your browser scope in web/router.ex:
    resources "/posts", PostController

Remember to update your repository by running migrations:
    $ mix ecto.migrate

Получаем ошибку! Чтобы исправить её, а заодно сделать интерфейс постов доступным из браузера, давайте откроем файл web/router.ex, и добавим в root-скоуп следующую строчку:

resources "/posts", PostController

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

$ mix ecto.migrate

Вывод:

Compiling 9 files (.ex)

Generated pxblog app

15:52:20.004 [info]  == Running Pxblog.Repo.Migrations.CreatePost.change/0 forward
15:52:20.004 [info]  create table posts
15:52:20.019 [info]  == Migrated in 0.0s

И, наконец, перезапускаем наш сервер заходим на страницу http://localhost:4000/posts, где мы должны увидеть заголовок Listing posts, а также список наших постов.

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

Шаг 1Б. Написание тестов для постов


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

Во-первых, откроем файл test/models/post_test.exs и взглянем на содержимое:

defmodule Pxblog.PostTest do
  use Pxblog.ModelCase
  
  alias Pxblog.Post
  
  @valid_attrs %{body: "some content", title: "some content"}
  @invalid_attrs %{}
  
  test "changeset with valid attributes" do
    changeset = Post.changeset(%Post{}, @valid_attrs)
    assert changeset.valid?
  end
  
  test "changeset with invalid attributes" do
    changeset = Post.changeset(%Post{}, @invalid_attrs)
    refute changeset.valid?
  end
end

Давайте разбирать этот код по частям, чтобы понять, что же тут происходит.
defmodule Pxblog.PostTest do
Очевидно, что нам нужно определить тестовый модуль в пространстве имён нашего приложения.
use Pxblog.ModelCase
Далее, мы говорим этому модулю использовать функции и DSL, представленные в наборе макросов ModelCase.
alias Pxblog.Post
Теперь убеждаемся, что тест может обращаться к модели напрямую.
@valid_attrs %{body: “some content”, title: “some content”}
Настраиваем основные действительные атрибуты, которые позволят успешно создать ревизию (changeset). Это просто переменная уровня модуля, которую мы можем дёргать каждый раз, когда хотим создать валидную модель.
@invalid_attrs %{}
Как и выше, но создаём набор недействительных атрибутов.
test "changeset with valid attributes" do
  changeset = Post.changeset(%Post{}, @valid_attrs)
  assert changeset.valid?
end
Теперь, мы непосредственно создаём наш тест функцией test, дав ему строковое имя. Внутри тела нашей функции сначала мы создаём из модели Post ревизию (передавая пустую структуру и список действительных параметров). Затем с помощью функции assert проверяем валидность ревизии. Это как раз то, для чего нам нужна переменная @valid_attrs.
test "changeset with invalid attributes" do
  changeset = Post.changeset(%Post{}, @invalid_attrs)
  refute changeset.valid?
end
Наконец, мы проверяем создание ревизии с недействительными параметрами, и вместо «утверждения», что ревизия валидная, выполняем обратную операцию refute.

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

defmodule Pxblog.PostControllerTest do
  use Pxblog.ConnCase

  alias Pxblog.Post
  
  @valid_attrs %{body: "some content", title: "some content"}
  @invalid_attrs %{}
  ...
end

Здесь используется Pxblog.ConnCase для получения специального DSL уровня контроллера. Остальные строки должны быть уже знакомы.

Посмотрим на первый тест:

test "lists all entries on index", %{conn: conn} do
  conn = get conn, post_path(conn, :index)
  assert html_response(conn, 200) =~ "Listing posts"
end

Здесь мы захватываем переменную conn, которая должна быть отправлена через блок настройки в ConnCase. Я объясню это позже. Следущий шаг — воспользоваться одноименной подходящему HTTP-методу функцией, чтобы совершить запрос по нужному пути (GET-запрос к действию :index в нашем случае). Затем мы проверяем, что ответ данного действия возвращает HTML со статусом 200 («OK») и содержит фразу Listing posts.

test "renders form for new resources", %{conn: conn} do
  conn = get conn, post_path(conn, :new)
  assert html_response(conn, 200) =~ "New post"
end

Следующий тест по сути такой же, только уже проверяется действие new. Всё просто.

test "creates resource and redirects when data is valid", %{conn: conn} do
  conn = post conn, post_path(conn, :create), post: @valid_attrs
  assert redirected_to(conn) == post_path(conn, :index)
  assert Repo.get_by(Post, @valid_attrs)
end

А здесь мы делаем что-то новенькое. Во-первых, отправляем POST-запрос со списком действительных параметров по адресу post_path. Мы ожидаем получить перенаправление на список постов (действие :index). Функция redirected_to принимает в качестве аргумента объект соединения, так как нам нужно знать, куда произошло перенаправление.

Наконец, мы утверждаем, что объект, представленный этими действительными параметрами был успешно добавлен в базу данных. Эта проверка осуществляется через запрос к репозиторию Ecto Repo на поиск модели Post, соответствующей нашим параметрам @valid_attrs.

test "does not create resource and renders errors when data is invalid", %{conn: conn} do
  conn = post conn, post_path(conn, :create), post: @invalid_attrs
  assert html_response(conn, 200) =~ "New post"
end

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

test "shows chosen resource", %{conn: conn} do
  post = Repo.insert! %Post{}
  conn = get conn, post_path(conn, :show, post)
  assert html_response(conn, 200) =~ "Show post"
end

Чтобы протестировать действие show, нам нужно создать модель Post, с которой и будем работать. Затем вызовем функцию get с хелпером post_path, и убедимся, что возвращается соответствующий ресурс.

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

test "renders page not found when id is nonexistent", %{conn: conn} do
  assert_error_sent 404, fn ->
    get conn, post_path(conn, :show, -1)
  end
end

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

Остальные тесты лишь повторяют вышеперечисленное для оставшихся путей. А вот на удалении остановимся подробнее:

test "deletes chosen resource", %{conn: conn} do
  post = Repo.insert! %Post{}
  conn = delete conn, post_path(conn, :delete, post)
  assert redirected_to(conn) == post_path(conn, :index)
  refute Repo.get(Post, post.id)
end

В целом, всё похоже, за исключением использования HTTP-метода delete. Мы утверждаем, что должны быть перенаправлены со страницы удаления обратно к списку постов. Ещё мы используем здесь новую фишку — «отвергаем» существование объекта Post с помощью функции refute.

Шаг 2. Добавляем пользователей


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

$ mix phoenix.gen.html User users username:string email:string password_digest:string

Вывод:

* creating web/controllers/user_controller.ex
...
Add the resource to your browser scope in web/router.ex:
    resources "/users", UserController

Remember to update your repository by running migrations:
    $ mix ecto.migrate

Далее откроем файл web/router.ex и добавим следующую строчку в тот же скоуп, что и ранее:

resources "/users", UserController

Синтаксис здесь определяет стандартный ресурсовый путь, где первым аргументом идёт URL, а вторым — имя класса контроллера. Затем выполним:

$ mix ecto.migrate

Вывод:

Compiling 11 files (.ex)
Generated pxblog app
16:02:03.987 [info]  == Running Pxblog.Repo.Migrations.CreateUser.change/0 forward
16:02:03.987 [info]  create table users
16:02:03.996 [info]  == Migrated in 0.0s

Наконец, перезагружаем сервер и проверяем http://localhost:4000/users. Теперь, помимо постов, мы имеем возможность добавлять и пользователей!

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

Давайте приведём этот экран в вид более похожий на регистрацию.

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

Шаг 3. Сохранение хеша пароля, вместо самого пароля


Открыв адрес /users/new, мы видим три поля: Username, Email и PasswordDigest. Но ведь когда вы регистрируетесь на других сайтах, вас просят ввести не дайджест пароля, а сам пароль вместе с его подтверждением! Как мы можем это исправить?

В файле web/templates/user/form.html.eex удалите следующие строки:

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

И добавьте на их место:

<div class="form-group">
  <%= label f, :password, "Password", class: "control-label" %>
  <%= password_input f, :password, class: "form-control" %>
  <%= error_tag f, :password %>
</div>

<div class="form-group">
  <%= label f, :password_confirmation, "Password Confirmation", class: "control-label" %>
  <%= password_input f, :password_confirmation, class: "form-control" %>
  <%= error_tag f, :password_confirmation %>
</div>

После обновления страницы (должно происходить автоматически) введите данные пользователя и нажмите кнопку Submit.

Упс, ошибка:

Oops, something went wrong! Please check the errors below.

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

Начнём с изменения схемы. В файле web/models/user.ex добавим пару строк:

schema "users" do
  field :username, :string
  field :email, :string
  field :password_digest, :string
  
  timestamps

  # Virtual Fields
  field :password, :string, virtual: true
  field :password_confirmation, :string, virtual: true  
end

Обратите внимание на добавление двух полей :password и :password_confirmation. Мы объявили их в качестве виртуальных полей, так как на самом деле их не существует в нашей базе данных, но они должны существовать как свойства в структуре User. Это также позволяет применять преобразования в нашей функции changeset.

Затем мы добавим :password и :password_confirmation в список обязательных полей:

def changeset(struct, params \\ %{}) do
  struct
  |> cast(params, [:username, :email, :password, :password_confirmation])
  |> validate_required([:username, :email, :password, :password_confirmation])
end

Если вы сейчас попробуете запустить тесты из файла test/models/user_test.exs, то тест “changeset with valid attributes” упадёт. Это происходит потому, что мы добавили :password и :password_confirmation к обязательным параметрам, но не обновили @valid_attrs. Давайте изменим эту строчку:

@valid_attrs %{email: "[email protected]", password: "test1234", password_confirmation: "test1234", username: "testuser"}

Наши тесты моделей должны снова проходить! Теперь нужно починить тесты контроллеров. Внесём некоторые изменения в файл test/controllers/user_controller_test.exs. Вначале выделим валидные атрибуты для создания объекта в отдельную переменную:

@valid_create_attrs %{email: "[email protected]", password: "test1234", password_confirmation: "test1234", username: "testuser"}
@valid_attrs %{email: "[email protected]", username: "testuser"}

Затем изменим наш тест на создание пользователя:

test "creates resource and redirects when data is valid", %{conn: conn} do
  conn = post conn, user_path(conn, :create), user: @valid_create_attrs
  assert redirected_to(conn) == user_path(conn, :index)
  assert Repo.get_by(User, @valid_attrs)
end

И тест на обновление пользователя:

test "updates chosen resource and redirects when data is valid", %{conn: conn} do
  user = Repo.insert! %User{}
  conn = put conn, user_path(conn, :update, user), user: @valid_create_attrs
  assert redirected_to(conn) == user_path(conn, :show, user)
  assert Repo.get_by(User, @valid_attrs)
end

После того, как наши тесты снова стали зелёными, нам нужно добавить в changeset функцию, преобразующую пароль в дайджест:

def changeset(struct, params \\ %{}) do
  struct
  |> cast(params, [:username, :email, :password, :password_confirmation])
  |> validate_required([:username, :email, :password, :password_confirmation])
  |> hash_password
end

defp hash_password(changeset) do
  changeset
  |> put_change(:password_digest, "ABCDE")
end

Пока мы просто стабим поведение нашей хеширующей функции. Первым делом давайте убедимся, что изменение ревизии происходит корректно. Вернёмся в браузер на страницу http://localhost:4000/users, кликнем на ссылку New user и создадим нового пользователя с любыми данными. Теперь в списке пользователей нас ожидает новая строка, дайджест пароля в которой равен ABCDE.

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

test "password_digest value gets set to a hash" do
  changeset = User.changeset(%User{}, @valid_attrs)
  assert get_change(changeset, :password_digest) == "ABCDE"
end

Это большой шаг вперёд для приложения, но не такой большой для безопасности! Нужно скорее исправить хеширование пароля на настоящее с использованием BCrypt, любезно предоставленной библиотекой Comeonin.

Для этого откройте файл mix.exs и добавьте :comeonin в список applications:

def application do
    [mod: {Pxblog, []},
     applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext, :phoenix_ecto, :postgrex, :comeonin]]
end

Также нам нужно изменить наши зависимости. Обратите внимание на {:comeonin, “~> 2.3”}:

defp deps do
  [{:phoenix, "~> 1.2.0"},
   {:phoenix_pubsub, "~> 1.0"},
   {:phoenix_ecto, "~> 3.0"},
   {:postgrex, ">= 0.0.0"},
   {:phoenix_html, "~> 2.6"},
   {:phoenix_live_reload, "~> 1.0", only: :dev},
   {:gettext, "~> 0.11"},
   {:cowboy, "~> 1.0"},
   {:comeonin, "~> 2.3"}]
end

Теперь отключим запущенный сервер и выполним команду `mix deps.get`. Если все пройдет хорошо (а оно должно!), то командой `iex -S mix phoenix.server` вы снова сможете запустить сервер.

Наш старый метод hash_password неплох, но вообще-то нам нужно, чтобы пароль хешировался на самом деле. Так как мы добавили библиотеку Comeonin, которая предоставляет нам прекрасный модуль Bcrypt с методом hashpwsalt, который мы импортируем в нашу модель User. В файле web/models/user.ex добавьте приведённую ниже строчку сразу следом за use Pxblog.Web, :model:

import Comeonin.Bcrypt, only: [hashpwsalt: 1]

Что мы сейчас сделали? Мы вытянули модуль Bcrypt из пространства имён Comeonin и импортировали метод hashpwsalt с арностью 1. А следующим кодом мы заставим функцию hash_password работать:

defp hash_password(changeset) do
  if password = get_change(changeset, :password) do
    changeset
    |> put_change(:password_digest, hashpwsalt(password))
  else
    changeset
  end
end

Предлагаю попробовать создать пользователя ещё раз! На этот раз, после регистрации мы должны увидеть шифрованный дайджест в поле password_digest!

Теперь давайте немного доработаем функцию hash_password. Во-первых, чтобы шифрование пароля не тормозило тестирование, необходимо внести изменения в настройки тестового окружения. Для этого откройте файл config/test.exs и добавьте следующую строчку в самый низ:

config :comeonin, bcrypt_log_rounds: 4

Это скажет библиотеке Comeonin не слишком сильно шифровать пароль во время выполнения тестов, поскольку в тестах нам важнее скорость, чем безопасность! А в продакшене (файл config/prod.exs) нам наоборот нужно усилить защиту:

config :comeonin, bcrypt_log_rounds: 14

Давайте напишем тест для вызова Comeonin. Мы сделаем его менее подробным, так как хотим лишь убедиться в работе шифрования. В файле test/models/user_test.exs:

test "password_digest value gets set to a hash" do
  changeset = User.changeset(%User{}, @valid_attrs)
  assert Comeonin.Bcrypt.checkpw(@valid_attrs.password, Ecto.Changeset.get_change(changeset, :password_digest))
end

Для улучшения покрытия тестами, давайте рассмотрим случай, когда строчка `if the password = get_change() не является истиной:

test "password_digest value does not get set if password is nil" do
  changeset = User.changeset(%User{}, %{email: "[email protected]", password: nil, password_confirmation: nil, username: "test"})
  refute Ecto.Changeset.get_change(changeset, :password_digest)
end

В данном случае поле password_digest должно оставаться пустым, что и происходит! Мы проделываем хорошую работу, покрывая наш код тестами!

Шаг 4. Давайте войдём!


Добавим новый контроллер SessionController и сопутствующее представление SessionView. Начнём с простого, а со временем придём к более правильной реализации.

Создайте файл web/controllers/session_controller.ex:

defmodule Pxblog.SessionController do
  use Pxblog.Web, :controller

  def new(conn, _params) do
    render conn, "new.html"
  end
end

А также web/views/session_view.ex:

defmodule Pxblog.SessionView do
  use Pxblog.Web, :view
end

И напоследок web/templates/session/new.html.eex:

<h2>Login</h2>

Следующую строку добавьте в скоуп "/":

resources "/sessions", SessionController, only: [:new]

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

Перейдя по адресу http://localhost:4000/sessions/new, мы должны увидеть под заголовком Phoenix framework подзаголовок Login.

Добавим сюда настоящую форму. Для этого создадим файл web/templates/session/form.html.eex:

<%= form_for @changeset, @action, fn f -> %>
  <%= if f.errors != [] do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below:</p>
      <ul>
        <%= for {attr, message} <- f.errors do %>
          <li><%= humanize(attr) %> <%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="form-group">
    <label>Username</label>
    <%= text_input f, :username, class: "form-control" %>
  </div>

  <div class="form-group">
    <label>Password</label>
    <%= password_input f, :password, class: "form-control" %>
  </div>

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

И вызовем только что созданию форму в файле web/templates/session/new.html.eex с помощью всего лишь одной строчки:

<%= render "form.html", changeset: @changeset, action: session_path(@conn, :create) %>

Благодаря автоматической перезагрузке кода, на странице отобразится ошибка, так как мы ещё не определили переменную @changeset, которая как можно догадаться должна быть ревизией. Раз мы работаем с объектом, у которого есть поля :name и :password, давайте их и использовать!

В файле web/controllers/session_controller.ex нам необходимо добавить алиас модели User, чтобы мы спокойно могли к ней обращаться дальше. В верхней части нашего класса, прямо под строкой use Pxblog.Web, :controller добавьте следующее:

alias Pxblog.User

И в функции new измените вызов рендера, как показано ниже:

render conn, "new.html", changeset: User.changeset(%User{})

Мы должны передать сюда объект соединения, шаблон который мы хотим отрендерить (без расширения eex) и список дополнительных переменных, которые будут использоваться внутри шаблона. В данном случае нам нужно указать changeset: и передать ему ревизию Ecto для User с пустой структурой пользователя.

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

No helper clause for Pxblog.Router.Helpers.session_path/2 defined for action :create.
The following session_path actions are defined under your router:    
*:new

В нашей форме мы ссылаемся на путь, которого пока не существует. Мы используем хелпер session_path, передавая ему объект @conn, но затем указываем путь :create, который ещё только предстоит создать.

Половина пути пройдена. Теперь давайте реализуем возможность реального входа с использованием сессии. Для этого изменим наши пути.

В файле web/router.ex включим :create в описание SessionController:

resources "/sessions", SessionController, only: [:new, :create]

В файле web/controllers/session_controller.ex импортируем функцию checkpw из модуля Bcrypt библиотеки Comeonin:

import Comeonin.Bcrypt, only: [checkpw: 2]

В этой строке говорится “Импортируй из модуля Comeonin.Bcrypt только функцию checkpw с арностью 2".

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

plug :scrub_params, "user" when action in [:create]

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

Следом добавим функцию для обработки действия create. Расположим её внизу модуля SessionController. Здесь будет много кода, так что давайте разберём его по частям.

В файле web/controllers/session_controller.ex:

def create(conn, %{"user" => user_params}) do
  Repo.get_by(User, username: user_params["username"])
  |> sign_in(user_params["password"], conn)
end

Первый кусочек кода Repo.get_by(User, username: user_params[“username”]) вытаскивает подходящего пользователя User из нашего репозитория Ecto Repo, если username совпадает либо возвращает nil.

Вот небольшой кусочек вывода, чтобы проверить это поведение:

iex(3)> Repo.get_by(User, username: "flibbity")
[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."username" = $1) ["flibbity"] OK query=0.7ms
nil

iex(4)> Repo.get_by(User, username: "test")
[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."username" = $1) ["test"] OK query=0.8ms
%Pxblog.User{__meta__: %Ecto.Schema.Metadata{source: "users", state: :loaded},
 email: "test", id: 15,
 inserted_at: %Ecto.DateTime{day: 24, hour: 19, min: 6, month: 6, sec: 14,
  usec: 0, year: 2015}, password: nil, password_confirmation: nil,
 password_digest: "$2b$12$RRkTZiUoPVuIHMCJd7yZUOnAptSFyM9Hw3Aa88ik4erEsXTZQmwu2",
 updated_at: %Ecto.DateTime{day: 24, hour: 19, min: 6, month: 6, sec: 14,
  usec: 0, year: 2015}, username: "test"}

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

defp sign_in(user, password, conn) when is_nil(user) do
  conn
  |> put_flash(:error, "Invalid username/password combination!")
  |> redirect(to: page_path(conn, :index))
end

defp sign_in(user, password, conn) do
  if checkpw(password, user.password_digest) do
    conn
    |> put_session(:current_user, %{id: user.id, username: user.username})
    |> put_flash(:info, "Sign in successful!")
    |> redirect(to: page_path(conn, :index))
  else
    conn
    |> put_session(:current_user, nil)
    |> put_flash(:error, "Invalid username/password combination!")
    |> redirect(to: page_path(conn, :index))
  end
end

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

Вторая функция будет обрабатывать все остальные сценарии (когда охранное условие ложно). Мы проверяем пароль функцией checkpw. Если он правильный, то записываем пользователя в переменную сессии current_user и осуществляем перенаправление с сообщением об успешном входе. В противном случае, мы очищаем текущий сеанс пользователя, устанавливаем сообщение об ошибке, и перенаправляем к корню.

Мы можем зайти на страницу http://localhost:4000/sessions/new и проверить как оно работает. С правильными данными мы войдём внутрь, а с неправильными получим ошибку.

Нам также нужно написать тесты на этот контроллер. Создадим файл test/controllers/session_controller_test.exs и заполним его следующим кодом:

defmodule Pxblog.SessionControllerTest do
  use Pxblog.ConnCase
  alias Pxblog.User

  setup do
    User.changeset(%User{}, %{username: "test", password: "test", password_confirmation: "test", email: "[email protected]"})
    |> Repo.insert
    {:ok, conn: build_conn()}
  end

  test "shows the login form", %{conn: conn} do
    conn = get conn, session_path(conn, :new)
    assert html_response(conn, 200) =~ "Login"
  end

  test "creates a new user session for a valid user", %{conn: conn} do
    conn = post conn, session_path(conn, :create), user: %{username: "test", password: "test"}
    assert get_session(conn, :current_user)
    assert get_flash(conn, :info) == "Sign in successful!"
    assert redirected_to(conn) == page_path(conn, :index)
  end

  test "does not create a session with a bad login", %{conn: conn} do
    conn = post conn, session_path(conn, :create), user: %{username: "test", password: "wrong"}
    refute get_session(conn, :current_user)
    assert get_flash(conn, :error) == "Invalid username/password combination!"
    assert redirected_to(conn) == page_path(conn, :index)
  end

  test "does not create a session if user does not exist", %{conn: conn} do    
    conn = post conn, session_path(conn, :create), user: %{username: "foo", password: "wrong"}
    assert get_flash(conn, :error) == "Invalid username/password combination!"
    assert redirected_to(conn) == page_path(conn, :index)
  end
end

Начинаем со стандартного блока setup и довольно обычной проверки GET-запроса. Тест на создание выглядит более интересным:

test "creates a new user session for a valid user", %{conn: conn} do
  conn = post conn, session_path(conn, :create), user: %{username: "test", password: "test"}
  assert get_session(conn, :current_user)
  assert get_flash(conn, :info) == "Sign in successful!"
  assert redirected_to(conn) == page_path(conn, :index)
end

Первая строчка — это отправка POST-запроса на путь создания сессии. Затем идут проверки, установилась ли переменная сессии current_user, появилось сообщение о входе, и, наконец, осуществилось ли перенаправление. В остальных тестах мы точно так же проверяем другие пути, куда может попасть функция sign_in. Опять же всё очень просто!

Шаг 5. Улучшаем нашего current_user


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

Для этого в файл web/views/layout_view.ex добавим хелпер, который облегчит получение информации о текущем пользователе:

def current_user(conn) do
  Plug.Conn.get_session(conn, :current_user)
end

Теперь откроем файл web/templates/layout/app.html.eex и вместо ссылки Get Started добавим следующее:

<li>
    <%= if user = current_user(@conn) do %>
      Logged in as
      <strong><%= user.username %></strong>
      <br>
      <%= link "Log out", to: session_path(@conn, :delete, user.id), method: :delete %>
    <% else %>
      <%= link "Log in", to: session_path(@conn, :new) %>
    <% end %>
</li>

Давайте снова разбирать код по шагам. Одной из первых вещей, которую нам нужно сделать — это выяснить кто текущий пользователь, предполагая, что он уже вошёл в систему. Сначала сделаем решение в лоб, а рефакторингом займёмся после. Установим пользователя из сессии прямо в нашем шаблоне. Функция get_session — это часть объекта Conn.

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

Нам также нужно вывести имя текущего пользователя. Мы храним структуру пользователя в сессионной переменной current_user, так что у нас есть возможность получить username через user.username.

Если мы не смогли найти пользователя, то просто показываем ссылку на вход. Здесь мы снова рассматриваем сессию как ресурс, так что new предоставит правильный путь для создания новой сессии.

Вы наверное заметили, что после обновления страницы мы получаем ещё одно сообщение с ошибкой об отсутствующей функции. Давайте подключим требуемый путь, чтобы Phoenix был счастлив!

В файле web/router.ex добавляем к маршрутам сессии также и :delete:

resources "/sessions", SessionController, only: [:new, :create, :delete]

Ещё нужно изменить контроллер. В файл web/controllers/session_controller.ex добавьте следующее:

def delete(conn, _params) do
  conn
  |> delete_session(:current_user)
  |> put_flash(:info, "Signed out successfully!")
  |> redirect(to: page_path(conn, :index))
end

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

Теперь мы можем войти, выйти и проверить неудачный вход. Всё идёт к лучшему! Но прежде нам нужно написать несколько тестов. Мы начнём с тестов для нашего LayoutView. Первое, что мы собираемся сделать, это прописать алиасы для модулей LayoutView и User, чтобы сократить код. Далее в блоке настройки мы создаём пользователя и добавляем его в базу данных. А затем возвращаем стандартный кортеж {:ok, conn: build_conn()}.

defmodule Pxblog.LayoutViewTest do
  use Pxblog.ConnCase, async: true
  alias Pxblog.LayoutView
  alias Pxblog.User

  setup do
    User.changeset(%User{}, %{username: "test", password: "test", password_confirmation: "test", email: "[email protected]"})
    |> Repo.insert
    {:ok, conn: build_conn()}
  end

  test "current user returns the user in the session", %{conn: conn} do
    conn = post conn, session_path(conn, :create), user: %{username: "test", password: "test"}
    assert LayoutView.current_user(conn)
  end

  test "current user returns nothing if there is no user in the session", %{conn: conn} do
    user = Repo.get_by(User, %{username: "test"})
    conn = delete conn, session_path(conn, :delete, user)
    refute LayoutView.current_user(conn)
  end
end


Теперь рассмотрим сами тесты. В первом из них мы создаём сессию и утверждаем, что функция LayoutView.current_user должна вернуть определённые данные. Во втором же рассмотрим обратную ситуацию. Мы явно удаляем сессию и опровергаем, что функция current_user возвращает пользователя.

Также мы добавили действие delete в SessionController, следовательно на это тоже нужно написать тест:

test "deletes the user session", %{conn: conn} do
  user = Repo.get_by(User, %{username: "test"})
  conn = delete conn, session_path(conn, :delete, user)
  refute get_session(conn, :current_user)
  assert get_flash(conn, :info) == "Signed out successfully!"
  assert redirected_to(conn) == page_path(conn, :index)
end

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

На этом первая часть подошла к концу.

Важное заключение от переводчика


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

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


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


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

Comments 44

    +3
    Спасибо за перевод.
    У вас бы быстрее популяризация пошла, если бы Вы начали переводить для начала вводную по elixir в целом. Подобные статьи довольно трудно воспринимать людям, которые не знакомы (либо плохо знакомы) с синтаксисом самого языка. :)
      +1
      Благодарю за комментарий. В целом я с вами согласен, но вводные статьи не так интересны, как те, в которых можно получить реальный результат, который можно сразу же пощупать. Поэтому сначала покажем как это круто, а дальше уже сформируем интересные обучающие материалы с нуля.
        +3
        Считаю что данная статья ненамного полезнее вводной. «Блог на эрланге» звучит примерно так же как «high-load чат-сервер на bash-скриптах» или «реалтаймовый кодировщик видео и фреймсервер на php» — возможно, но зачем? У нас есть отличный отбойный молоток, но мы им качаем детскую кроватку, а не крошим бетон.

        Всю статью по написанию блога на эликсире можно сократить до:
        docker run --name some-wordpress -e WORDPRESS_DB_HOST=10.1.2.3:3306 \
        -e WORDPRESS_DB_USER=… -e WORDPRESS_DB_PASSWORD=… -d wordpress

        На освободившемся месте можно рассказать, как на эликсире легко и непринуждённо пишутся whatsapp'ы и видеокодировщики, как миллионы акторов крутятся в стройном танце, обслуживая сотни тысяч игроков в agar.io-клоне на сервере из двух ардуин. Тогда, я думаю, и популяризация пошла бы семимильными шагами. А очередной блог на хаскеле или c++ порождает всё тот же вопрос — зачем? Может всё-таки задачу, пусть и учебную, подобрать более релевантную имеющемуся инструменту?
          +3
          Итак, по порядку) Во-первых, это первая статья в цикле из 11 частей, в остальных будут рассматриваться различные интересные возможности Феникса, прекрасно подходящие для создания современного блога, а-ля та же упоминаемая в комментариях Медуза. Во-вторых это первая статья в рамках развития русскоязычного сообщества Эликсира, было бы странно сразу рассматривать какой-то конкретный кейс создания хайлоад-сервера. Об этом несомненно будет дальше. В-третьих, создания блога для веб-разработки — это как Todomvc для Js, зная другие подобные инструменты легко можно провести параллели в технологиях. Ну и вообще, эта статья — прощупывания почвы на предмет интереса к сабжекту.
            +1
            Я тоже не очень понимаю зачем делать блог на Erlang/Elixir. Но, справедливости ради, есть даже конкурент Wordpress под Erlang, Zotonic называется.
        0
        Прекрасный пример современной веб-разработки, которая не работает на шареде и представляет из себя булыжник и напильник. Судя по нумерации в названии статьи, запуск проекта на локальной машине предлагается растянуть на несколько дней. Удобно.
          +2

          Если вы на шареде, вам не сто́ит писать свой движок блога.


          Время, необходимое на запуск проекта, не играет никакой роли. Имеет смысл обсуждать время на поддержку проекта. Но, к сожалению, этот цикл (я читал оригинал) не дает представления «зачем это вообще», почему в продакшене иногда важен «hot update» и «zero downtime», и так далее.


          Если вам надо что-то, что поднимется за пять минут и задеплоится на шаред — лучшей альтернативы jekyll’у люди не придумали.

            0
            Присовокупите туда сервер, который тоже надо админить, обновлять, чинить дырки, беречь от 0-day и ddos и т.д.
            лучшей альтернативы jekyll’у люди не придумали.

            Я что-то пропустил и руби теперь дают на шаредах?
              +1
              сервер, который тоже надо админить, обновлять, чинить дырки, беречь от 0-day и ddos и т.д.

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


              Я что-то пропустил и руби теперь дают на шаредах?

              Даже на github-pages :)
              Jekyll генерирует статический сайт.

                0
                Для этого надо где-то запустить билд :) Какие-то странные телодвижения ради поста в блоге
                  +1

                  github-pages запустит билд для вас. git commit -m 'New post' + git push и ваш блог обновлен. Мне неизвестны способы, требующие меньшего количества телодвижений.

                    –5
                    Пока кто-ковыряется в консоли, ребята из Виллабаджо не парятся и пишут в блог на любом движке на PHP c mysql без всяких коммитов, маркдаунов и тд
                      0

                      Когда у вас будет пара тысяч записей в блоге и задача «отыскать записи, со ссылками на habr (с упоминаниями Серёги)» станет более-менее повседневной, ребята в консоли продолжат тратить на обновление блога секунду, а в Виллабаджо начнется эротический танец вприсядку, потому что grep в том месте, в котором вы сейчас оказались (например, в телефоне) работает одинаково быстро, а вот поиск по вашей базе в шареде может оказаться уже не настолько прозрачным.


                      Ну и кроме того, хранить текстовые записи в html в базе — это извращение, которое умерло 20 лет назад.

                        –2
                        Ну да, ну да, осталось только научиться секрктарш заказчиков пользоваться грепом и гитом
                          +1

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


                          Тут просто как бы тема про другое, а вы влезли с настолько же амбициозным, насколько и неумным обобщением ad web developentum.

                            –2
                            Тема про создание сферическиех бложиков в вакууме аж на несколько выпусков
                              +4
                              Эта статья не для создания блога, а как пример работы с фениксом…
                              То, что люди побегут делать (и выкладывать на шареды) свои блоги после прочтения статьи на фениксе очень и очень маловероятно.
                              Ваши придирки вообще не обоснованы, просто уходите дальше писать на php и обновлять блоги на шареде.
                0

                Ну и кроме того, да, руби дают на очень многих дешевых шаредах.

              0

              А зачем вообще нужно русскоязычное сообщество (не канальчик в слаке, а именно целое сообщество)? Чем плохо коммитить в мировое пространство?


              Вот сравните, скажем, вклад в развитие языка медузы и thoughtbot’а (две первые картинки у вас). Пока оно вот так, кого вы ждете у себя в сообществе? Я не обличаю, мне действительно интересно: зачем бы мне рассказывать своим друзьям про команду, которая «готовит крутой проект», когда вокруг полно команд (и просто людей), которые крутые проекты ведут открыто, с возможностью пощупать код и приобщиться к разработке? Если у меня у самого, скажем, три активно развивающихся проекта?

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

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

                Если у вас у самого 3 активно развивающихся проекта, особенно если они преследуют образовательные цели, да ещё и бесплатно дают доступ к информации, да ещё и на тему Эликсира, то предлагаю скорее поделиться ими с сообществом! Наш проект тоже абсолютно открытый, но пока находится в процессе формирования. Не надеюсь, что вы о нём сейчас расскажете друзьям, однако практически уверен, что сделаете это через некоторое время.
                  0
                  Я давно поделился всем с сообществом, с тем сообществом, которое не скрывает от меня название своих замечательных проектов и не куклится в кокон по принципу «у нас тут уютненько».

                  > Не надеюсь, что вы о нём сейчас расскажете друзьям

                  У вас так на сайте написано, я же не сам придумал эту гениальную формулировку.
                    0
                    Я не скрываю — проект называется wunsh.ru, это и есть образовательный сервис. Первое, что было сделано в рамках него — данная статья.

                    Эта гениальная формулировка с заделом на будущее, надежды на неё сейчас мало, но святая вера всё же присутствует.
                      0
                      Я с одной стороны искренне желаю вам и вашему проекту удачи. Серьезно, было бы здорово, если бы взлетело. Образовательные проекты — замечательно, вне зависимости от.

                      С другой стороны, я не сторонник велосипедостроения. В экосистеме Elixir’а на сегодняшний день полно́ незанятых зияющих ниш: бери и воплощай. Но у вас на сайте не найти ссылки ни на один захудалый пакетик на hex.pm (или я плохо искал?) Как и у медузы, кстати.

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

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

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

                        Ну а получится ли у меня это, покажет только время.
                  0
                  канальчик в слаке, в телеграме и прочее — это помоечка для ежедневного трепа.

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

                  Причем если это было в IRC/jabber, то ответ как раз найти можно. В отличие от слака/телеграма.
                    –1
                    В точку. А вы, что, гуглите профессиональные вопросы на русском? Если да — соболезную. Если нет — см. мой предыдущий комментарий.

                  0
                  шаред хостинг  — это уже почившее в Бозе явление. 5 долларов в месяц digitalocean.
                    0
                    И сколько будет стоить плюсом его админить?
                    Плюс другое облако для бэкапов
                    Плюс настройка всего?
                    И где гарантия что админ на фрилансе не захостит там пяток своих спам-рассыльщиков
                      0
                      а где гарантия, что фриланс-админ не сделает это на шаред хостинге?
                      или на шаред только заливка по фтп и все?
                      тогда вы можете с ВПС-ки сделать тоже самое. один раз настроить по туториалам того же ДО и потом только по фтп выливать свой бложик
                        0
                        Туториалы устаревают, версии софта меняются, вы говорите о совершенно разном уровне ответственности за одинаковый вроде бы прайс, но он не одинаковый
                          +1
                          странно говорить о некой «отвественности», если система хостится на шаред-хостинге, который может похерится по вине любого из вашей соседей.

                          еще раз. вам достаточно один раз потратить от 15 минут до нескольких часов (хотя для стандартной убунты все ставится и настраивается с пол пинка) на первоначальную настройку и получите в свое распоряжение все тот же шаред, но без соседей. профит на лицо
                            0
                            Да, шаред-хостинги несут ответственность за свои действия, работая по договору, получая деньги по безналу. Облака не несут ответственности ни за что. Но мы куда-то углубились очень глубоко
                              +1
                              тот, кто сдал вам ВПС несет такую же отвественность. поломка железа — его отвесвтенность.
                              но вот когда изза макаки соседа по шареду ваш сайт начнет спамить, то никто вам ничего не должен. так что без соседей спокойней

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

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

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

                    Лучше рассказать про вебсокеты, компоновку ответов от разных источников на сервере и т.п.

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

                        Будем ждать
                      –2
                      Знаете, посмотрел на ваш wunsh.ru

                      Если честно, то сильно брезгую медузой. Мне очень противно оставлять вам свою почту, пока этот проект по непонятной причине там находится.
                        0
                        Мы никак не аффилированы с ними. Это лишь один из немногих русскоязычных проектов, который открыто заявляет об использовании Эликсира. Ну а редакционная политика всё же не должна так сильно влиять на мнение о совершенно другом мире исходного кода.
                          0
                          да, стараюсь себя в этом убеждать, но не очень получается.
                      • UFO just landed and posted this here
                        • UFO just landed and posted this here
                            0
                            Пока товарищи там заняты запуском крутейшего проекта, отвечу я :)

                            http://www.phoenixframework.org/docs/sessions

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

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