Кадр из фильма "Социальная Сеть"
Кадр из фильма "Социальная Сеть"

Зачем и для кого эта статья?

Всем привет! Я Ruby on Rails Developer и еще совсем недавно я начинал свой путь в этой области. Я уже прошел первые шаги (о них я писал в данной статье), как выбор языка, изучение его основ, знакомство с фреймворком, первые pet-проекты, первые собеседования, первый оффер, первая компания. Но многие только начали идти по этому пути и именно для них эта статья. По своему опыту помню, как сложно искать гайды (большинство из них про создание книжных магазинов, личных блогов и т.д.), поэтому, надеюсь, многим понравиться идея создания соц сети.

Почему соц сеть и как будет идти процесс?

Во-первых, мне самому было бы интересно реализация данного проекта. Во-вторых, я вдохновился книгой Practical Rails Social Networking Sites by Alan Bradburne. Можно было бы сделать все по книге, скажете вы. Но зачем тогда я и мои статьи? Книга 2007 года и версия ruby там 1.8, поэтому решения в большинстве своем будут неактуальны в наше время. К тому же, я не собираюсь делать все по книге, а лишь руководствоваться ей (дизайн буду использовать из нее с добавлением bootsrap). Во время разработки я буду использовать многие гемы, которые будут полезны для начинающих разработчиков. Но скажу сразу: эта серия статей не для самого старта. Некоторые базовые вещи (установка ruby, rails, что такое MVC, git и тому подобное) я буду пропускать, поэтому рекомендую отложить данный цикл статей и вернуться к нему чуть позже. Если вы опытный разработчик и читаете эту статью, буду очень признателен услышать ваше мнение. Касательно периодичности выхода статей и сколько именно их будет не могу сказать, так как планирую делать проект в свободное время от работы и параллельно писать статьи. Но буду стараться не откладывать в долгий ящик и делать все с хорошим темпом.

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

Перед тем, как мы начнем, установите следующие версии:

  • Ruby 3.0.3

  • Rails 6.1.4.6

  • MySQL 8.0

  • Node 10.19

  • Yarn 1.22.17

Очень советую вам использовать github во время разработки данного проекта. Чуть позже мы с вами настроим CI/CD на нашем проекте, что будет крайне полезным для вас опытом. Теперь создадим наш проект. Я назову его g_connect, но вы можете использовать любое другое название (если выберите другое, везде, где я буду использовать g_connect, пишите свое).

rails new g_connect -d mysql

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

#Gemfile

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '3.0.3'

gem 'aasm'
gem 'bootsnap', '>= 1.4.4', require: false
gem 'bootstrap'
gem 'devise'
gem 'jbuilder', '~> 2.7'
gem 'mysql2', '~> 0.5.2'
gem 'puma', '~> 5.0'
gem 'rails', '~> 6.1.4.6'
gem 'sass-rails', '>= 6'
gem 'slim'
gem 'turbolinks', '~> 5'
gem 'webpacker', '~> 5.0'

group :development, :test do
  gem 'better_errors'
  gem 'binding_of_caller'
  gem 'byebug', platforms: %i[mri mingw x64_mingw]
  gem 'factory_bot_rails'
  gem 'faker'
  gem 'rails-controller-testing'
  gem 'rspec-rails'
  gem 'rubocop'
  gem 'rubocop-rails'
  gem 'rubocop-rspec'
end

group :development do
  gem 'annotate'
  gem 'listen', '~> 3.3'
  gem 'rack-mini-profiler', '~> 2.0'
  gem 'spring'
  gem 'web-console', '>= 4.1.0'
end

group :test do
  gem 'capybara', '>= 3.26'
  gem 'rspec_junit_formatter'
  gem 'selenium-webdriver'
  gem 'simplecov', require: false
  gem 'webdrivers'
end

gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]

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

  • gem 'aasm' - его мы будем использовать для транзакций между состояниями

  • gem 'bootstrap' - это нам нужно будет для нашего дизайна (я делаю упор на бэк и совсем немного буду уделять время фронту, но в самом конце может и придет вдохновение на наведение красоты)

  • gem 'devise' - аутентификация (коротко и ясно, будет еще гем для авторизации, но я еще не выбрал, какой)

  • gem 'slim' - сэкономим время на написании html-тегов

  • gem 'better_errors' - красивый вывод ошибок в браузере (например, роут указали неправильный)

  • gem 'factory_bot_rails' - шаблон, который будем использовать в наших тестах

  • gem 'faker' - для создания фейковых данных

  • gem 'rspec-rails - это подключаем среду тестирования RSpec для нашего проекта

  • gem 'rubocop' - проверяем, насколько хорошо написан наш код

  • gem 'annotate' - для автоматической аннотации наших моделей (зачем переключаться между моделей и миграцией, если есть этот гем)

  • gem 'simplecov' - для просмотра, все ли мы покрыли тестами

Теперь нужно установить все гемы и их зависимости, поэтому запускаем bundle в нашем терминале (кстати да, забыл сказать, что использую Ubuntu, поэтому для MacOS/Windows(вот винду вообще не трогайте лучше при разработке на ruby, но уж если оч хочется) смотрите некоторые моменты самостоятельно). Также можем удалить папку test, она не понадобиться (ведь будем писать rspec-и).

После этого настроим нашу БД. В файле config/database.yml укажите свои username и password (я стандартно делал root/root). После этого запускаем следующее (если кто-то не знает, то эта одна команда сразу же выполняет db:create, db:schema:load и db:seed):

rails db:setup

Некоторые из наших гемов требуют дополнительной настройки. Ими мы сейчас и займемся (devise также требует дополнительной настройки, но им мы займемся позже, когда будем делать аутентификацию). Начнем с bootstrap. Переходим в файл app/assets/stylesheets/application.scss (файл может вначале иметь расширение .css, поэтому исправьте его на .scss) и добавляем следующую строчку:

/*app/assets/stylesheets/application.scss*/

@import "bootstrap";

Теперь настроим annotate. Для этого в терминале запустите следующую команду:

rails g annotate:install

Теперь нужно настроить rspec:

rails g rspec:install

Также для наших тестов нам понадобится настройка factory_bot_rails и simplecov. В нашей папке spec создаем папку support, а в ней создаем файл factory_bot.rb со следующим кодом:

#spec/support/factory_bot.rb

RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
end

Теперь переходим в наш spec/rails_helper.rb. Здесь мы подключим наш файл для factory_bot, а так же подключим simplecov (две строчки для подключения simplecov должны быть в самом начале файла).

#spec/rails_helper.rb

require 'simplecov'
SimpleCov.start 'rails'
require_relative './support/factory_bot'

С настройкой гемов пока что закончили. Если вы хотите проверить процент покрытия тестами, то можете запустить xdg-open coverage/index.html и вот она магия. Но я бы хотел добавить еще пару моментов. Первый - shared_context.rb для тестов наших моделей. В папке spec/support создайте shared_context.rb

#spec/support/shared_context.rb

RSpec.shared_examples 'creates_object_for' do |model_name|
  subject { FactoryBot.build(model_name) }

  it 'creates object' do
    expect { subject.save }.to change { described_class.count }.by(1)
  end
end

RSpec.shared_examples 'not_create_object_for' do |model_name, parameter|
  subject { described_class.create(attributes) }

  let(:attributes) { FactoryBot.attributes_for(model_name, parameter) }

  it 'does not create object' do
    expect { subject.save }.to change { described_class.count }.by(0)
  end

  it 'raise RecordInvalid error' do
    expect { subject.save! }.to raise_error(ActiveRecord::RecordInvalid)
  end
end

Наш shared_context будет играть роль шаблона для тестирования моделей. В нем мы опишем, что должно происходить, если данные валидны и объект создаётся, и наоборот, что будет происходить, если какие-то данные невалидны либо отсутствуют и объект не будет создаваться. Это заметно упростит написание тестов для моделей, потом на практике вы в этом убедитесь. Теперь давайте подключим его в наш spec_helper.rb

#spec/spec_helper.rb

require_relative './support/shared_context'

И последнее по настройкам перед стартом: чутка дизайна. Перейдите в app/views/layouts/application.html.erb. Измените расширение .erb на .slim и сделайте вот так:

#app/views/layouts/application.html.slim

doctype html
html
  head
    meta content='text/html; charset=UTF-8' http-equiv='Content-Type'
    title G-Connect
    = csrf_meta_tags
    = csp_meta_tag
    = stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
    = javascript_pack_tag 'application', 'data-turbolinks-track': 'reload'
  body
    #container
      #header
        
      #sidemenu
        = render 'application/sidemenu'
      #content
        = yield

Как по мне, это намного лучше смотрится, чем .html.erb. Если вы до этого никогда не использовали .slim, то используйте вот этот ресурс для перевода из .html.erb в .html.slim. Далее в папке app/views создаем папку application, а в ней файл _sidemenu.html.slim и внутри него пока что только следующие строчки:

#app/views/layouts/_sidemenu.html.slim

ul
  li
    = link_to 'Home', '/', class: 'btn btn-sm btn-light'

Затем переходим в app/assets/stylesheets/application.scss и добавляем следующее:

/*app/assets/stylesheets/application.scss*/

body {
   margin: 0;
   padding: 0;
   background-color: #f0ffff;
   font-family: Arial, Helvetica, sans-serif;
 }

 #header {
   background-color: #f0ffff;
   height: 60px;
   margin-top: 10px;
   text-align: left;
   padding-top: 1px;
 }

 #container {
   width: 760px;
   min-width: 760px;
   margin: 0 auto;
   padding: 0px;
 }

 #sidemenu {
   font-size: 80%;
   float: left;
   width: 100px;
   padding: 0px;
 }

 #sidemenu ul {
   list-style: none;
   margin-left: 0px;
   padding: 0px;
 }

 a {
   color: #b00;
 }

 a:hover {
   background-color: #b00; color: #f0ffff;
 }

 #content {
   float: right;
   width: 650px;
 }

 th {
   background-color: #933;
   color: #f0ffff;
 }

 tr.odd {
   background-color: #fcc;
 }

 tr.even {
   background-color: #ecc;
 }

Пока совсем простенько (css взят из книги), но, как я и говорил ранее, я делаю упор на бэк. Когда первичная работа окончена, можем двигаться дальше. Может залить все, что мы с вами сделали, на github и делать новые вещи уже в другой ветке. Рекомендую перед этим сделать проверку rubocop-ом (можете добавить специальное расширение для вашей IDE и он сразу будет подсвечивать вам файлы, в которых есть недочеты).

Создание модели страниц

Первую модель мы сделаем для страниц. У нее будут следующие поля

Имя поля

Тип

id

integer

title

string

permalink

string

body

text

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

rails g model Page

Помимо модели у нас сгенерировалась миграция и два файла для тестов, к ним мы вернемся чуть позже. Миграция находится в папке db/migrate. Давайте начнем с нее:

#db/migrate/date_time_create_pages.rb

class CreatePages < ActiveRecord::Migration[6.1]
  def change
    create_table :pages do |t|
      t.string :title,          null: false
      t.string :permalink
      t.text :body,             null: false
    end
  end
end

После этого запускаем rails db:migrate (наш annotate сразу же показывает, какие файлы были аннотированы) и переходим в нашу модель. Здесь мы пропишем наши валидации, а так же метод для получения нашего permalink (его мы будем вызывать с помощью коллбэка after_create). В этом методе я буду использовать регулярные выражения, для помощи с их составлением я всегда использую Rubular.

#app/model/page.rb

class Page < ApplicationRecord
  after_create :clean_url

  validates_presence_of :title, :body
  validates :title, length: { in: 3..250 }
  validates :body, length: { in: 3..100_00 }

  private

  def clean_url
    return unless self.permalink.nil?
    url = title.downcase.gsub(/\s+/, '_').gsub(/[^a-zA-Z0-9_]+/, '')
    self.permalink = url
    save
  end
end

Теперь предлагаю заняться нашими тестами. Начнем с файла spec/factories/pages.rb

#spec/factories/pages.rb

FactoryBot.define do
  factory :page do
    title { 'Test' }
    body { 'Test' }
  end
end

После этого можем заняться непосредственно написанием тестов. Именно в этом файле нам и пригодится наш написанный ранее spec/supprot/shared_context.rb.

#spec/models/page_spec.rb

require 'rails_helper'

RSpec.describe Page, type: :model do
  describe '.create' do
    context 'with valid attributes' do
      include_examples 'creates_object_for', :page
    end

    context 'with invalid attributes' do
      context 'with short title' do
        include_examples 'not_create_object_for', :page, title: 'te'
      end

      context 'with too long title' do
        include_examples 'not_create_object_for', :page, title: Faker::String.random(length: 253)
      end

      context 'with short body' do
        include_examples 'not_create_object_for', :page, body: 'te'
      end

      context 'with too long body' do
        include_examples 'not_create_object_for', :page, body: Faker::String.random(length: 100_02)
      end
    end

    context 'with missing attributes' do
      context 'with missing title' do
        include_examples 'not_create_object_for', :page, title: nil
      end

      context 'with missing body' do
        include_examples 'not_create_object_for', :page, body: nil
      end
    end
  end
end

Думаю, здесь не нужно никаких пояснений, лишь отмечу, что private методы не нуждаются в тестировании, поэтому здесь и нет тестов для нашего clean_url. Можем запустить rspec в нашем терминале и убедиться, что все наши тесты проходят без ошибок. Когда с моделью и тестами для них мы разобрались, предлагаю заняться контроллером. Я не использую генератор для контроллеров, поэтому создаем файл pages_controller.rb в нашей папке app/controllers. Здесь мы пропишем следующее:

#app/controllers/pages_controller.rb

class PagesController < ApplicationController
  before_action :find_page, only: %i[show edit update destroy]

  def index
    @pages = Page.all
  end

  def show; end

  def new
    @page = Page.new
  end

  def create
    @page = Page.create(page_params)

    if @page.save
      redirect_to pages_path, notice: 'Page created'
    else
      render :new
    end
  end

  def edit; end

  def update
    if @page.update(page_params)
      redirect_to page_path(@page), notice: 'Page updated'
    else
      render :edit
    end
  end

  def destroy
    @page.destroy
    redirect_to pages_path, notice: 'Page deleted'
  end

  private

  def find_page
    @page = Page.find(params[:id])
  end

  def page_params
    params.require(:page).permit(:title, :permalink, :body)
  end
end

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

#config/routes.rb

Rails.application.routes.draw do
  root 'pages#index'

  resources :pages
end

Теперь давайте покроим тестами наш контроллер. Для это в папке spec создаем папку controllers и там же сразу создаем файл pages_controller_spec.rb

#spec/controllers/pages_controller_spec.rb

require 'rails_helper'

RSpec.describe PagesController, type: :controller do
  describe 'GET #index' do
    let(:pages) { [FactoryBot.create(:page)] }

    it 'returns all pages' do
      get :index

      expect(response).to render_template('index')
      expect(response).to have_http_status(:ok)
      expect(assigns(:pages)).to eq(pages)
    end
  end

  describe 'GET #show' do
    let(:page) { FactoryBot.create(:page) }

    it 'assigns page' do
      get :show, params: { id: page.id }

      expect(response).to render_template('show')
      expect(response).to have_http_status(:ok)
      expect(assigns(:page)).to eq(page)
    end
  end

  describe 'GET #new' do
    it 'returns render form for creating new page' do
      get :new

      expect(response).to render_template('new')
      expect(response).to have_http_status(:success)
    end
  end

  describe 'POST #create' do
    let(:page_params) { FactoryBot.attributes_for(:page) }

    it 'creates new page' do
      get :create, params: { page: page_params }

      expect(response).to redirect_to('/pages')
      expect(response).to have_http_status(:found)
    end

    it 'doesn`t create new page' do
      get :create, params: { page: page_params.except(:title) }

      expect(response).to render_template('new')
    end
  end

  describe 'PUT #update' do
    let(:page) { FactoryBot.create(:page) }

    it 'updates the requested page' do
      put :update, params: { id: page.id, page: { title: 'brbrbr' } }

      expect(response).to redirect_to("/pages/#{page.id}")
      expect(response).to have_http_status(:found)
    end

    it 'doesn`t update page' do
      put :update, params: { id: page.id, page: { title: '' } }

      expect(response).to render_template('edit')
    end
  end

  describe 'DELETE #destroy' do
    let(:page) { FactoryBot.create(:page) }

    it 'destroys page' do
      delete :destroy, params: { id: page.id }

      expect(response).to redirect_to('/pages')
    end
  end
end

Теперь же предлагаю сделать визуал для нашего контроллера. Переходим в app/views и создаем там папку pages, а в ней 5 файлов: _form.htm.slim, new.html.slim, edit.html.slim, show.html.slim и index.html.slim. Теперь пройдемся по каждому из них. В нашем _form.htm.slim будет находиться форма, которую мы будем заполнять для создания или изменения наших Pages. Эту форму мы будем рендерить в наших new и edit соответственно.

#app/views/pages/_form.html.slim

= form_with(model: page, local: true) do |f|
  .form-group
    = f.label :title
    = f.text_field :title
  .form-group
    = f.label :body
    = f.text_area :body
  .form-group
    = f.submit 'Submit', class: 'btn btn-success'
#app/views/pages/new.html.slim

h1 New Page

= render 'form', page: @page

= link_to 'Back', :back, class: 'btn btn-sm btn-primary'
#app/views/pages/edit.html.slim

h1 Edit Page

= render 'form', page: @page

= link_to 'Back', :back, class: 'btn btn-sm btn-primary'

Теперь займемся show и index:

#app/views/pages/show.html.slim

p
  strong Title: 
  = @page.title
p
  strong Body: 
  = @page.body

= link_to 'Edit', edit_page_path(@page), class: 'btn btn-sm btn-success'
'
= link_to 'Delete', page_path(@page), method: :delete, class: 'btn btn-sm btn-danger', data: { confirm: 'Are you sure?' }
'
= link_to 'Back', :back, class: 'btn btn-sm btn-primary'
#app/views/pages/index.html.slim

h2 Pages
ul
  - @pages.each do |page|
    li
      = page.permalink
      |: 
      = page.title
      |
      p  
        = link_to 'Show', page_path(page), class: 'btn btn-sm btn-info'

p
  = link_to 'Create new page', new_page_url, class: 'btn btn-sm btn-primary'

И последняя вещь на сегодня - немного подправим _sidemenu.html.slim

#app/views/application/_sidemenu.html.slim 

ul
  li
    = link_to 'Home', root_path, class: 'btn btn-sm btn-light'

Думаю, на сегодня уже достаточно. Итак довольно объемная статья получилась. Проверьте все rubocop-ом, исправьте недочеты, если нужно, и можете смело заливать на ваш github. В следующей статье мы добавим пользователей, devise и настроим CI/CD. Надеюсь вам всем было интересно читать эту статью. Если есть какие-то замечания либо же предложения - смело пишите в комментариях. Желаю всем поменьше ошибок в коде и побольше интересных проектов!