Пишем сообщество phpland на Laravel - Часть 1

Начинаю серию статей, в которых я буду кратко описывать процесс создание сообщества "phpland" на PHP фреймворки Laravel. Исходный код будет опубликован на Github под лицензией MIT позже.

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

В этой статье я исхожу из позиции что мы хорошо знаем PHP, ООП, MySQL, умеем работать с composer и Git, а также прошли хотябы базовый видео курс по Laravel. Я не эксперт по фреймворку Laravel, но имею большой опыт в работе с Yii2 и Zend Framework, поэтому думаю довольно быстро разберусь в нём вместе с вами. Если будут вопросы, советы и т.д. пишите в комментарии.

Планы

  • Мы установим PHP фреймворк Laravel и сделаем базовую настройку
  • Создадим новый маршрут для сущности Page, а также всё необходимое (Migration, Controller, Model и View) и результате получим работаютщий CRUD (Create Read Update Delete), то есть мы сможем добавлять, редактировать и удалять статьи и страницы
  • Мы сделаем пагинацию для статей блога
  • Создадим регистрацию и авторизацию на сайте
  • Настроим PHP Code Sniffer для проекта

Итак, поехали.

Установка Laravel

composer create-project laravel/laravel phpland

Теперь мы можем перейти в папку phpland, где находится наш новый проект на Laravel.

Даём необходимые права на запись (Linux only):

chmod -R 777 storage bootstrap/cache

Настраиваем веб-сервер (VirtualHost)

Необходимо настроить локальный веб-сервер так, чтобы при обращение к домейну (сайту), запрос передавался в файл "index.php", который находится в папки "/public".

Если у нас Apache (например XAMPP)

<VirtualHost *:80>
DocumentRoot "/opt/lampp/htdocs/PhpstormProjects/phpland/public/"
ServerName phpland.test
</VirtualHost>

Добавляем тестовый домен "phpland.test" в файл hosts

127.0.0.1 phpland.test

То есть теперь если мы обратимся по адресу phpland.test -> нам откроется файл index.php из папки "public" и мы должны увидить страницу приветствия Laravel.

Структура Laravel

Laravel как и большенство других PHP фреймворков придерживается MVC (Model-View-Controller).

  • app -> здесь находится наше приложение включая модели (Model)
  • app/Http/Controllers -> здесь находятся наши котроллеры (Controller)
  • resources/views -> здесь находятся наши представления (View)
  • resources/sass -> здесь находятся SASS файлы
  • resource/js -> здесь находятся JS файлы
  • routes/web.php -> здесь находятся наши маршруты
  • storage -> здесь хранятся служебные временные файлы
  • public -> здесь находятся скомпелированные и сжатые css, js файлы, а также наш файл "index.php"
  • ".env" и ".env-example" -> наш конфиг, который считывает данные из файлов внутри папки config

Инициализируем новый проект Git и делаем первый коммит

Так как я работаю с IDE PhpStorm, мне нужно добавить в .gitignore папку ".idea", чтобы она не добавлялась в Git.

Отредактируем файл "readme.md" и поместим туда:

## phpland

Source code of the phpland community.

## Contributing

1. Fork it
2. Create new issue on Github and copy the id (Example: 1)
3. Create your feature branch (git checkout -b issue1)
4. Make your changes
5. Run PHP Code Sniffer and resolve all errors
6. Commit your changes (git commit -am 'Added some feature')
7. Push to the branch (git push origin issue1)
8. Create new Pull Request

## License

The phpland project is open-source software licensed under the [MIT license](https://opensource.org/licenses/MIT).

Создадим файл "license.md" и поместим туда лицензию MIT:

MIT License

Copyright (c) 2019 Alexander Schilling

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Теперь можно сделать первый коммит:

git init
git add --all
git commit -am 'Init commit'

Настраиваем приложение для локальной разработки

Открываем файл ".env" и изменяем там следующие настройки:

APP_NAME=phpland
APP_URL=http://phpland.test

DB_DATABASE=phpland
DB_USERNAME=root
DB_PASSWORD=

При установки, Laravel сам создал копию файла .env.example и назвал его ".env", этот файл не нужно добавлять в Git. Если мы захотим добавить новые настройки, редактировать нужно оба файла.

Создаём базу данных "phpland" через phpmyadmin (utf8_general_ci).

Пишем первую миграцию для сущности Pages

Перед тем, как мы начнём писать нашу миграцию, давайте немного поговорим про нашу сущность "Pages". Так как страница блога и обычная страница мало чем отличается, мы можем использовать один котроллер, модель, маршрут, форму и .д. Всё что нам нужно сделать, это добавить поле "material_id", где будет храниться тип страницы. Например "blog" для записей блога и "static" для страниц. А уже при выводе фильтровать.

Прежде чем мы создадим нашу миграцию, нам нужно сделать ещё один шаг.

Отредактируем файл "app/Providers/AppServiceProvider.php" и добавим в "boot":

\Illuminate\Support\Facades\Schema::defaultStringLength(191);

Это исправляет странный баг, при пременении нашей миграции.

Создаём новую миграцию:

php artisan make:migration create_pages_table

В папки "database/migrations" появится наша первая миграция: "2019_06_09_074908_create_pages_table.php", отредактируем её.

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreatePagesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {

        Schema::create('pages', function (Blueprint $table) {

            $table->bigIncrements('id');
            $table->string('title', 255);
            $table->string('slug')->unique();
            $table->text('content');
            $table->enum('status_id', ['draft', 'public'])->default('draft');
            $table->unsignedInteger('category_id')->default(0);
            $table->unsignedBigInteger('owner_id')->default(0);
            $table->unsignedBigInteger('hits')->default(0);
            $table->enum('allow_comments', [0, 1])->default(1);
            $table->enum('mainstream', [0, 1])->default(0);
            $table->string('material_id', 10)->default('blog');
            $table->timestamps();

        });

    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('pages');
    }
}

Здесь мы описали как должна будет выглядить структура сущности "Pages", а также добавили Foreign Key к таблице Users на будущее.

"mainstream" будет хранить значение да (1) или нет (0) и означает что страница блога будет отображаться в общей ленте.

Другие поля думаю понятны и без комментариев :-)

Теперь мы можем применить наши миграции:

php artisan migrate

Мы заметим что по мимо нашей миграции, выполняются также миграции которые идут по умолчанию в Laravel.

Если мы загляниним в нашу БД, то должны увидить там таблицу "pages" и описанную выше структуру.

Создаём роутинг для страниц

Наш роутинг для страниц (Page) будет выглядить следующим образом:

POST | page | page.store  | App\Http\Controllers\PagesController@store
GET  | page | page.index  | App\Http\Controllers\PagesController@index
GET  | page/create | page.create | App\Http\Controllers\PagesController@create
PATCH | page/{page} | page.update | App\Http\Controllers\PagesController@update
GET  | page/{page} | page.show | App\Http\Controllers\PagesController@show
DELETE    | page/{page} | page.destroy | App\Http\Controllers\PagesController@destroy
GET  | page/{page}/edit | page.edit | App\Http\Controllers\PagesController@edit

В зависимости от HTTP запроса (GET, POST, PATCH, DELETE), мы будем обращаться к котроллеру PagesController и вызывать у него соответсвующий action (index, show...). То есть если мы перейдём по маршруту "/page", это будет у нас запрос "GET" и мы обратимся к котроллеру PagesController и к методу "index" и увидим список всех страниц.

Чтобы не писать всё в ручную, в Laravel существует тип роутинга "resource", который создаёт все необходимые маршруты за нас. Нам следует только предерживаться правил наименнования "action" чтобы всё работало.

Отредактируем файл "routes/web.php" и добавим туда:

Route::resource('/page', 'PagesController');

Теперь у нас есть необходимые нам маршруты, посмотреть весь список можно с помощью команды:

php artisan route:list

Но у нас ещё нету котроллера, поэтому давайте с начало создадим его.

Создаём модель, котроллер для сущности "Pages"

В Laravel принятно называть котроллер в множественном числе например "Pages", а модель в едином то есть "Page".

Artisan поможет нам создать всё необходимое:

php artisan make:controller PagesController -r -m Page

Опция "-r" (resource) - создаст нам все необходимые action для нашего resource "Page". Опция "-m" (model) - создаст для нас модель "Page".

Наш контроллер находится здесь: "app/Http/Controllers/PagesController.php" и если мы откроем его, то увидим что Laravel сгенерировал для нас все необходимые методы для resource "Page".

Наша модель находится здесь: "app/Page.php"

Создаём авторизацию на сайте

Для этого в Laravel уже всё есть, нам нужно только выполнить команду:

php artisan make:auth

Если мы перейдём на наш сайт http://phpland.test/, то увидим страницу "welcome" и свехру кнопки для авторизации и регистрации.

Адаптируем котроллер под наши нужды

Откроем файл "app/Http/Controllers/PagesController.php" и изменим его на:

<?php

namespace App\Http\Controllers;

use App\Http\Requests\PageRequest;
use App\Page;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;

class PagesController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return View
     */
    public function index(): View
    {
        $pages = Page::where('material_id', 'blog')
            ->where('mainstream', '1')
            ->where('status_id', 'public')
            ->orderBy('created_at', 'desc')
            ->paginate(config('pages.posts_per_page'));

        return view('pages.index')->with('pages', $pages);
    }

    /**
     * Show the form for creating a new resource.
     *
     * @return View
     */
    public function create(Page $page): View
    {
        return view('pages.create')->with(['page' => $page]);
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  PageRequest  $pageRequest
     *
     * @return RedirectResponse
     */
    public function store(PageRequest $pageRequest): RedirectResponse
    {

        Page::create($pageRequest->validated());

        return redirect()->back()->with('message', 'Страница добавлена!');

    }

    /**
     * Display the specified resource.
     *
     * @param Page  $page
     *
     * @return View
     */
    public function show(Page $page): View
    {
        return view('pages.show')->with('page', $page);
    }

    /**
     * Show the form for editing the specified resource.
     *
     * @param  Page  $page
     *
     * @return View
     */
    public function edit(Page $page): View
    {
        return view('pages.edit')->with(['page' => $page]);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  PageRequest  $pageRequest
     * @param  Page         $page
     *
     * @return RedirectResponse
     */
    public function update(PageRequest $pageRequest, Page $page): RedirectResponse
    {
        $page->update($pageRequest->validated());

        return redirect()->back()->with('message', 'Страница сохранена!');
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param Page $page
     *
     * @return RedirectResponse
     */
    public function destroy(Page $page): RedirectResponse
    {
        $page->delete();

        return redirect('/page');
    }
}

Создадим конфиг для сущности Page

В папки "config", создадим новый файл "pages.php"

<?php

return [
    'posts_per_page' => env('POSTS_PER_PAGE', 10),
];

В нём мы указываем сколько статей должно выводиться на одной странице. По умолчанию 10. Если нам нужно будет изменить это значение, мы просто отредактируем файл .env и укажем там другое значение. Пример использование можно увидить выше в PagesController@index.

Создаём обработчик запросов для сущности Page

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

php artisan make:request PageRequest

Пропишем туда свои правила валидации.

$rules = [
            'category_id' => ['required', 'integer'],
            'title' => ['required', 'min:3', 'max:255'],
            'content' => ['required', 'string'],
            'slug' => ['nullable', 'alpha_dash', 'min:3', 'max:255'],
            'status_id' => ['alpha'],
            'owner_id' => ['integer'],
            'hits' => ['integer'],
            'allow_comments' => ['integer', 'between:0,1'],
            'mainstream' => ['integer', 'between:0,1'],
            'material_id' => ['alpha'],
        ];

        if ($this->method() === 'PUT' || $this->method() === 'PATCH') {
            array_push($rules['slug'], 'unique:pages,slug,' . $this->page->id);
        } else {
            array_push($rules['slug'], 'unique:pages,slug');
        }

        return $rules;

В методе "authorize()" прописываем "true", чтобы запросы обрабатывались только от зарегистрированных пользователей.

Обновим нашу модель Page

Откроем файл "app/Page.php" и заменим на:

/**
     * Allowed fields
     *
     * @var array
     */
    protected $fillable = [
        'category_id',
        'title',
        'slug',
        'content',
        'status_id',
        'hits',
        'allow_comments',
        'mainstream',
        'material_id',
    ];

    public const CATEGORIES = [
        0 => 'Выберите категорию',
        1 => 'Веб-программирование',
        2 => 'SEO',
        3 => 'Софт',
        4 => 'Сервер',
        5 => 'Игры',
        6 => 'Разное',
        7 => 'Истории успеха',
    ];

    public const STATUS = [
        'draft' => 'Черновик',
        'public' => 'Опубликовать',
    ];

    public const MATERIALS = [
        'blog' => 'Блог',
        'static' => 'Страница',
    ];

    /**
     * Get the route key for the model.
     *
     * @return string
     */
    public function getRouteKeyName(): string
    {
        return 'slug';
    }

Позже мы вынесим категории в БД, а пока так.

Создаём View для контроллера Pages

В папки "resources/views" создаем новую папку "pages", здесь будут храниться все Views нашего котроллера.

Создадим View для Index

Файл будет называться "index.blade.php":

@extends('layouts.app')

@section('content')

    @foreach($pages as $page)
        <div class="row">
            <div>
                <h3><a href="/page/{{$page->slug}}">{{ $page->title }}</a></h3>
                <div>{{ $page->content }}</div>
            </div>
        </div>
    @endforeach
    
    {{ $pages->links() }}

@endsection

Создадим View для Create

Файл будет называться "create.blade.php":

@extends('layouts.app')

@section('content')

    <h1>Добавить страницу</h1>

    @if(session()->has('message'))
        <div class="alert alert-success">
            {{ session()->get('message') }}
        </div>
    @endif

    <form method="POST" action="/page">
        @include('pages.form')
    </form>

@endsection

Создадим View для Show

Файл будет называть "show.blade.php":

@extends('layouts.app')

@section('content')

    <h1>{{$page->title}}</h1>

    @if($page->status_id === 'draft')
        (Черновик)
    @endif

    <div>
        {{$page->content}}
    </div>

    @if ($page->hits > 0)
        <div>
            Просмотров: {{$page->hits}}
        </div>
    @endif

    <div>
        Категория: {{$page->category_id}}
    </div>

    <div>
        {{$page->created_at->format('d.m.Y')}}
    </div>

    @if ($page->allow_comments)
        <div>Комментарии.</div>
    @endif

@endsection

Создадим View для Edit

Файл будет называться "edit.blade.php":

@extends('layouts.app')

@section('content')

    <h1>Изменить страницу</h1>

    @if(session()->has('message'))
        <div class="alert alert-success">
            {{ session()->get('message') }}
        </div>
    @endif

    <form method="post" action="/page/{{ $page->slug }}">

        @method('PATCH')
        @csrf

       @include('pages.form')

    </form>

    <form method="post" action="/page/{{ $page->slug }}">

        @method('DELETE')
        @csrf

        <div class="form-group">
            <button type="submit" class="btn btn-submit">Удалить</button>
        </div>

    </form>

@endsection

Создадим форму для добавление и редактирование страниц

Она будет называться "form.blade.php":

    @csrf

    <div class="form-group">
        <label for="material_id">Тип страницы</label>
        <select class="form-control" name="material_id" id="material_id">
            @foreach ($page::MATERIALS as $key => $value)
                <option value="{{ $key }}" {{ ( $key == old('material_id', $page->material_id ?? null)) ? 'selected' : '' }}>
                    {{ $value }}
                </option>
            @endforeach
        </select>
        @error('category_id')
            <div class="text-danger">{{ $message }}</div>
        @enderror
    </div>

    <div class="form-group">
        <label for="category_id">Категория</label>
        <select class="form-control" name="category_id" id="category_id">
            @foreach ($page::CATEGORIES as $key => $value)
                <option value="{{ $key }}" {{ ( $key == old('category_id', $page->category_id ?? null)) ? 'selected' : '' }}>
                    {{ $value }}
                </option>
            @endforeach
        </select>
        @error('category_id')
            <div class="text-danger">{{ $message }}</div>
        @enderror
    </div>

    <div class="form-group">
        <label for="title">Заголовок</label>
        <input type="text" name="title" class="form-control @error('title') is-invalid @enderror" id="title" value="{{ old('title', $page->title ?? null) }}">
        @error('title')
            <div class="text-danger">{{ $message }}</div>
        @enderror
    </div>

    <div class="form-group">
        <label for="slug">Постоянная ссылка</label>
        <input type="text" name="slug" class="form-control @error('slug') is-invalid @enderror" id="slug" value="{{ old('slug', $page->slug ?? null) }}">
        @error('slug')
            <div class="text-danger">{{ $message }}</div>
        @enderror
    </div>

    <div class="form-group">
        <label for="content">Текст</label>
        <textarea name="content" class="form-control @error('content') is-invalid @enderror" id="content">{{ old('content', $page->content ?? null) }}</textarea>
        @error('content')
            <div class="text-danger">{{ $message }}</div>
        @enderror
    </div>

    <div class="form-group">
        <label for="status_id">Статус</label>
        <select class="form-control" name="status_id" id="status_id">
            @foreach ($page::STATUS as $key => $value)
                <option value="{{ $key }}" {{ ( $key == old('status_id', $page->status_id ?? null)) ? 'selected' : '' }}>
                    {{ $value }}
                </option>
            @endforeach
        </select>
        @error('status_id')
            <div class="text-danger">{{ $message }}</div>
        @enderror
    </div>

    <div class="form-group">
        <div class="form-check">
            <input type="hidden" name="allow_comments" value="0">
            <input class="form-check-input" type="checkbox" name="allow_comments" id="allow_comments" {{ old('allow_comments', $page->allow_comments ?? null) ? 'checked' : '' }} value="1">
            <label class="form-check-label" for="allow_comments">Разрешить комментарии</label>
        </div>
        @error('allow_comments')
            <div class="text-danger">{{ $message }}</div>
        @enderror
    </div>

    <div class="form-group">
        <div class="form-check">
            <input type="hidden" name="mainstream" value="0">
            <input class="form-check-input" type="checkbox" name="mainstream" id="mainstream" {{ old('mainstream', $page->mainstream ?? null) ? 'checked' : '' }} value="1">
            <label class="form-check-label" for="mainstream">Добавить в майнстрим</label>
        </div>
        @error('mainstream')
            <div class="text-danger">{{ $message }}</div>
        @enderror
    </div>

    <div class="form-group">
        <button type="submit" class="btn btn-success btn-submit">Сохранить</button>
    </div>

Тестируем

Если мы перейдём на страницу: http://phpland.test/page то увидим страницу со всеми записями блога (а точнее пока что пустую страницу, так как у нас нету записей в блоге :).

Если мы перейдём по адресу: http://phpland.test/page/create то сможем добавить новую страницу или новую запись в блог (пока что лучше заполнять все поля, особенно "постоянная ссылка", "статус -> опубликовать" и выбираем "Добавить в майнстрим") иначе страница не появится на главной, мотому что мы уже фильтруем вывод.

Если мы допустим добавим страницу "Привет мир" и указажем в постоянной ссылки: "privet-mir", то мы сможем посмотреть эту запись (страницу) http://phpland.test/page/privet-mir

Чтобы отредактировать страницу нам достаточно в конце вписать "/edit". На этой же страницы мы можем удалить её.

Автоматическое создание Slug из заголовка страницы

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

Создадим нового провайдера, для работы с нашеми моделями Eloquent.

php artisan make:provider EloquentEventServiceProvider

В него поместим следующий код:

<?php

namespace App\Providers;

use App\Observers\PageObserver;
use App\Page;
use Illuminate\Support\ServiceProvider;

class EloquentEventServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap services.
     *
     * @return void
     */
    public function boot()
    {
        Page::observe(PageObserver::class);
    }
}

Подключим его, добавив его в массив в файл config/app.php:

// Application Service Providers
App\Providers\EloquentEventServiceProvider::class,

Далее создадим папку "app/Observers" и в ней создадим файл для работы с нашей сущностью "Page", то есть файл "PageObserver.php":

<?php

namespace App\Observers;

use App\Page;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;

class PageObserver
{

    public function creating(Page $page)
    {

        if (empty($page->slug)) {
            $page->slug = $this->generateUniqueSlug($page);
        }

        $page->owner_id = Auth::id();
    }

    public function updating(Page $page)
    {

        if (empty($page->slug)) {
            $page->slug = $this->generateUniqueSlug($page);
        }
    }

    private function generateUniqueSlug(Page $page): string {

        $tempSlug = Str::slug($page->title);

        $slug = Page::where('slug', $tempSlug)->first();

        return !empty($slug) ? $tempSlug . '-' . date('d-m-Y-H-i-s') : $tempSlug;
    }
}

Готово.

Настраиваем PHP code sniffer

Laravel придерживаются стандарту PSR-2, поэтому и мы будем его придерживаться в нашем проекте.

Подключим к нашему проекту php_codesniffer:

composer require --dev squizlabs/php_codesniffer

Добавим в composer.json два альаса, чтобы было удобнее запускать:

"scripts": {
        "cs-check": "phpcs",
        "cs-fix": "phpcbf"
    }

Также создадим файл "phpcs.xml" в корневой директории и поместим туда следующее содержание:

<?xml version="1.0"?>
<ruleset name="PSR2">    
<description>The PSR2 coding standard.</description>    
<rule ref="PSR2"/>     
<file>app/</file>     
<exclude-pattern>resources</exclude-pattern>
<exclude-pattern>storage/</exclude-pattern>
<exclude-pattern>vendor</exclude-pattern>    
</ruleset>

Теперь в корневой директории проекта, мы можем запускать проверку:

composer cs-check

И автоисправление командой:

composer cs-fix

Теперь мы легко можем прогнать наш год через PHP Code Sniffer и исправить ошибки.

Перед каждый коммитом, мы будем запускать эти две команды, чтобы наш код соответстовал PSR-2.

Создадим ещё один коммит:

git add --all
git commit -m "Add new page resource"

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

Посмотреть всё в виде коммита можно на Github: https://github.com/dignityinside/phpland_laravel/commit/0afb902e917e8621733e8bfd622c9e236b465b6a https://github.com/dignityinside/phpland_laravel/commit/77ee05a813d2635ed881378b933b136ac6baae72


Оставьте комментарий!

Если у Вас остались какие-либо вопросы, либо у Вас есть желание высказаться по поводу этой статьи, то Вы можете оставить свой комментарий:


Написать новый комментарий

Видео

Самые полезные видео на темы "веб-разработка", "Linux" и "IT". Смотри и обучайся!

Подробнее »

Сделки

Самые горячие и выгодные сделки, акции и скидки на видео-курсы, софт, услуги, книги и железо из всего рунета.

Подробнее »

Планета

Наша планета собирает интересные статьи из различных источников и объединяет их в одну ленту. Которую можно читать на нашем сайте.

Подробнее »