В этом руководстве мы собираемся создать приложение, которое взаимодействует с Google Drive API. Он будет иметь функции поиска файлов, загрузки, скачивания и удаления. Если вы хотите следовать, вы можете клонировать репо из Github .
Создание нового проекта Google
Первое, что нам нужно сделать при работе с любым из API Google, — это создать новый проект в консоли Google.
Затем нам нужно включить API Google+ и Google Drive API. Нам понадобится G +, чтобы получить информацию о пользователе.
В меню учетных данных мы выбираем Добавить учетные данные , выбирая идентификатор клиента OAuth 2.0 .
Далее идет кнопка настройки экрана согласия . Это позволяет нам вводить подробную информацию о проекте, такую как имя, URL домашней страницы и другие полезные для пользователя данные. Пока нам нужно только ввести название проекта.
Далее мы создаем идентификатор клиента. Нам нужно веб-приложение для типа приложения, а затем нам нужно ввести значение для URI авторизованного перенаправления . Это URL, на который Google будет перенаправлять после того, как пользователь даст разрешение на приложение.
Строим проект
Для этого проекта мы будем использовать Laravel Framework .
composer create-project --prefer-dist laravel/laravel driver
Установка зависимостей
Нам понадобится официальный клиент Google API для PHP, а также библиотека Carbon. Обновите composer.json
чтобы добавить эти:
composer require nesbot/carbon google/apiclient
Конфигурирование проекта
Нам нужно указать несколько глобальных настроек для нашего проекта. В Laravel это делается через файл .env
. Давайте отредактируем файл и добавим название приложения, часовой пояс и конфигурацию Google ниже конфигурации по умолчанию:
APP_ENV=local APP_DEBUG=true APP_KEY=base64:iZ9uWJVHemk5wa8disC8JZ8YRVWeGNyDiUygtmHGXp0= APP_URL=http://localhost SESSION_DRIVER=file #add these: APP_TITLE=Driver APP_TIMEZONE="Asia/Manila" GOOGLE_CLIENT_ID="YOUR GOOGLE CLIENT ID" GOOGLE_CLIENT_SECRET="YOUR GOOGLE CLIENT SECRET" GOOGLE_REDIRECT_URL="YOUR GOOGLE LOGIN REDIRECT URL" GOOGLE_SCOPES="email,profile,https://www.googleapis.com/auth/drive" GOOGLE_APPROVAL_PROMPT="force" GOOGLE_ACCESS_TYPE="offline"
Разбивая его, мы имеем:
-
GOOGLE_CLIENT_ID
иGOOGLE_CLIENT_SECRET
— идентификатор клиента и секрет, которые мы получили ранее из консоли Google. -
GOOGLE_REDIRECT_URL
— URL-адрес, на который Google будет перенаправлять после того, как пользователь разрешит наше приложение. Это должно быть то же самое, что было добавлено ранее в консоли Google. Это также должен быть действительный маршрут в приложении. Если мы использовали artisan для обслуживания проекта, URL перенаправления будет выглядеть примерно такhttp://localhost:8000/login
. -
GOOGLE_SCOPES
— разрешения, которые нам нужны от пользователя.email
дает нашему приложению доступ к электронной почте пользователя.profile
дает нам основную информацию о пользователе, такую как имя и фамилия.https://www.googleapis.com/auth/drive
дает нам разрешение на управление файлами на Google Диске пользователя.
Googl
Класс Googl
( app\Googl.php
) используется для инициализации клиентских библиотек Google и Google Drive. У него есть два метода: client
и drive
.
В методе client
мы инициализируем клиент Google API, а затем устанавливаем различные параметры, которые мы добавили ранее в файле .env
. После того, как все различные параметры установлены, мы просто возвращаем только что созданного клиента.
Метод drive
используется для инициализации службы Google Drive. Это принимает клиента в качестве аргумента.
<?php namespace App; class Googl { public function client() { $client = new \Google_Client(); $client->setClientId(env('GOOGLE_CLIENT_ID')); $client->setClientSecret(env('GOOGLE_CLIENT_SECRET')); $client->setRedirectUri(env('GOOGLE_REDIRECT_URL')); $client->setScopes(explode(',', env('GOOGLE_SCOPES'))); $client->setApprovalPrompt(env('GOOGLE_APPROVAL_PROMPT')); $client->setAccessType(env('GOOGLE_ACCESS_TYPE')); return $client; } public function drive($client) { $drive = new \Google_Service_Drive($client); return $drive; } }
Маршруты
Различные страницы в приложении определены в файле app/Http/routes.php
:
// This is where the user can see a login button for logging into Google Route::get('/', 'HomeController@index'); // This is where the user gets redirected upon clicking the login button on the home page Route::get('/login', 'HomeController@login'); // Shows a list of things that the user can do in the app Route::get('/dashboard', 'AdminController@index'); // Shows a list of files in the users' Google drive Route::get('/files', 'AdminController@files'); // Allows the user to search for a file in the Google drive Route::get('/search', 'AdminController@search'); // Allows the user to upload new files Route::get('/upload', 'AdminController@upload'); Route::post('/upload', 'AdminController@doUpload'); // Allows the user to delete a file Route::get('/delete/{id}', 'AdminController@delete'); Route::get('/logout', 'AdminController@logout');
Домашний контроллер
Домашний контроллер ( app/Http/Controller/HomeController.php
) отвечает за обслуживание домашней страницы и обработку входа пользователя.
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use App\Googl; class HomeController extends Controller { public function index() { return view('login'); } public function login(Googl $googl, Request $request) { $client = $googl->client(); if ($request->has('code')) { $client->authenticate($request->input('code')); $token = $client->getAccessToken(); $plus = new \Google_Service_Plus($client); $google_user = $plus->people->get('me'); $id = $google_user['id']; $email = $google_user['emails'][0]['value']; $first_name = $google_user['name']['givenName']; $last_name = $google_user['name']['familyName']; session([ 'user' => [ 'email' => $email, 'first_name' => $first_name, 'last_name' => $last_name, 'token' => $token ] ]); return redirect('/dashboard') ->with('message', ['type' => 'success', 'text' => 'You are now logged in.']); } else { $auth_url = $client->createAuthUrl(); return redirect($auth_url); } } }
Разбирая код выше, сначала мы возвращаем вид входа в систему:
public function index() { return view('login'); }
Представление входа в систему хранится в resources/views/login.blade.php
и содержит следующий код:
@extends('layouts.default') @section('content') <form method="GET" action="/login"> <button class="button-primary">Login with Google</button> </form> @stop
Представление входа в систему наследуется от файла resources/views/layouts/default.blade.php
:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>{{ env('APP_TITLE') }}</title> <link rel="stylesheet" href="{{ url('assets/css/skeleton.min.css') }}"> <link rel="stylesheet" href="{{ url('assets/css/style.css') }}"> </head> <body> <div class="container"> <header> <h1>{{ env('APP_TITLE') }}</h1> </header> @include('partials.alert') @yield('content') </div> </body> </html>
Мы используем Skeleton CSS Framework для этого приложения. Поскольку в Skeleton уже есть все, что нужно, чтобы все выглядело хорошо, у нас есть только минимальный код CSS ( public/assets/css/style.css
):
.file-title { font-size: 18px; } ul#files li { list-style: none; } .file { padding-bottom: 20px; } .file-modified { color: #5F5F5F; } .file-links a { margin-right: 10px; } .alert { padding: 20px; } .alert-success { background-color: #61ec58; } .alert-danger { background-color: #ff5858; }
Возвращаясь к Home Controller, у нас есть метод login
в login
где мы проверяем, есть ли code
переданный в качестве параметра запроса. Если его нет, мы создаем URL аутентификации и перенаправляем пользователя:
if ($request->has('code')) { ... } else { $auth_url = $client->createAuthUrl(); return redirect($auth_url); }
URL-адрес аутентификации — это, по сути, страница входа в Google, которую пользователь может использовать для предоставления разрешения приложению. После того как пользователь дал разрешение, Google перенаправляет обратно на указанный нами URL-адрес перенаправления. На этот URL передается code
, который мы используем для получения токена доступа.
$client->authenticate($request->input('code')); $token = $client->getAccessToken();
Оттуда мы получаем основную информацию о пользователе, которую затем сохраняем в сеансе вместе с токеном доступа:
$plus = new \Google_Service_Plus($client); $google_user = $plus->people->get('me'); $id = $google_user['id']; $email = $google_user['emails'][0]['value']; $first_name = $google_user['name']['givenName']; $last_name = $google_user['name']['familyName']; session([ 'user' => [ 'email' => $email, 'first_name' => $first_name, 'last_name' => $last_name, 'token' => $token ] ]);
Наконец, мы перенаправляем пользователя на страницу панели инструментов:
return redirect('/dashboard') ->with('message', ['type' => 'success', 'text' => 'You are now logged in.']);
Admin Controller
Контроллер администратора ( app/Http/Controllers/AdminController.php
) имеет дело с операциями, которые могут выполнять только зарегистрированные пользователи. Здесь находится код для перечисления, поиска, загрузки и удаления файлов.
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use App\Googl; use Carbon\Carbon; class AdminController extends Controller { private $client; private $drive; public function __construct(Googl $googl) { $this->client = $googl->client(); $this->client->setAccessToken(session('user.token')); $this->drive = $googl->drive($this->client); } public function index() { return view('admin.dashboard'); } public function files() { $result = []; $pageToken = NULL; $three_months_ago = Carbon::now()->subMonths(3)->toRfc3339String(); do { try { $parameters = [ 'q' => "viewedByMeTime >= '$three_months_ago' or modifiedTime >= '$three_months_ago'", 'orderBy' => 'modifiedTime', 'fields' => 'nextPageToken, files(id, name, modifiedTime, iconLink, webViewLink, webContentLink)', ]; if ($pageToken) { $parameters['pageToken'] = $pageToken; } $result = $this->drive->files->listFiles($parameters); $files = $result->files; $pageToken = $result->getNextPageToken(); } catch (Exception $e) { return redirect('/files')->with('message', [ 'type' => 'error', 'text' => 'Something went wrong while trying to list the files' ] ); $pageToken = NULL; } } while ($pageToken); $page_data = [ 'files' => $files ]; return view('admin.files', $page_data); } public function search(Request $request) { $query = ''; $files = []; if ($request->has('query')) { $query = $request->input('query'); $parameters = [ 'q' => "name contains '$query'", 'fields' => 'files(id, name, modifiedTime, iconLink, webViewLink, webContentLink)', ]; $result = $this->drive->files->listFiles($parameters); if($result){ $files = $result->files; } } $page_data = [ 'query' => $query, 'files' => $files ]; return view('admin.search', $page_data); } public function delete($id) { try { $this->drive->files->delete($id); } catch (Exception $e) { return redirect('/search') ->with('message', [ 'type' => 'error', 'text' => 'Something went wrong while trying to delete the file' ]); } return redirect('/search') ->with('message', [ 'type' => 'success', 'text' => 'File was deleted' ]); } public function upload() { return view('admin.upload'); } public function doUpload(Request $request) { if ($request->hasFile('file')) { $file = $request->file('file'); $mime_type = $file->getMimeType(); $title = $file->getClientOriginalName(); $description = $request->input('description'); $drive_file = new \Google_Service_Drive_DriveFile(); $drive_file->setName($title); $drive_file->setDescription($description); $drive_file->setMimeType($mime_type); try { $createdFile = $this->drive->files->create($drive_file, [ 'data' => $file, 'mimeType' => $mime_type, 'uploadType' => 'multipart' ]); $file_id = $createdFile->getId(); return redirect('/upload') ->with('message', [ 'type' => 'success', 'text' => "File was uploaded with the following ID: {$file_id}" ]); } catch (Exception $e) { return redirect('/upload') ->with('message', [ 'type' => 'error', 'text' => 'An error occurred while trying to upload the file' ]); } } } public function logout(Request $request) { $request->session()->flush(); return redirect('/')->with('message', ['type' => 'success', 'text' => 'You are now logged out']); } }
-<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use App\Googl; use Carbon\Carbon; class AdminController extends Controller { private $client; private $drive; public function __construct(Googl $googl) { $this->client = $googl->client(); $this->client->setAccessToken(session('user.token')); $this->drive = $googl->drive($this->client); } public function index() { return view('admin.dashboard'); } public function files() { $result = []; $pageToken = NULL; $three_months_ago = Carbon::now()->subMonths(3)->toRfc3339String(); do { try { $parameters = [ 'q' => "viewedByMeTime >= '$three_months_ago' or modifiedTime >= '$three_months_ago'", 'orderBy' => 'modifiedTime', 'fields' => 'nextPageToken, files(id, name, modifiedTime, iconLink, webViewLink, webContentLink)', ]; if ($pageToken) { $parameters['pageToken'] = $pageToken; } $result = $this->drive->files->listFiles($parameters); $files = $result->files; $pageToken = $result->getNextPageToken(); } catch (Exception $e) { return redirect('/files')->with('message', [ 'type' => 'error', 'text' => 'Something went wrong while trying to list the files' ] ); $pageToken = NULL; } } while ($pageToken); $page_data = [ 'files' => $files ]; return view('admin.files', $page_data); } public function search(Request $request) { $query = ''; $files = []; if ($request->has('query')) { $query = $request->input('query'); $parameters = [ 'q' => "name contains '$query'", 'fields' => 'files(id, name, modifiedTime, iconLink, webViewLink, webContentLink)', ]; $result = $this->drive->files->listFiles($parameters); if($result){ $files = $result->files; } } $page_data = [ 'query' => $query, 'files' => $files ]; return view('admin.search', $page_data); } public function delete($id) { try { $this->drive->files->delete($id); } catch (Exception $e) { return redirect('/search') ->with('message', [ 'type' => 'error', 'text' => 'Something went wrong while trying to delete the file' ]); } return redirect('/search') ->with('message', [ 'type' => 'success', 'text' => 'File was deleted' ]); } public function upload() { return view('admin.upload'); } public function doUpload(Request $request) { if ($request->hasFile('file')) { $file = $request->file('file'); $mime_type = $file->getMimeType(); $title = $file->getClientOriginalName(); $description = $request->input('description'); $drive_file = new \Google_Service_Drive_DriveFile(); $drive_file->setName($title); $drive_file->setDescription($description); $drive_file->setMimeType($mime_type); try { $createdFile = $this->drive->files->create($drive_file, [ 'data' => $file, 'mimeType' => $mime_type, 'uploadType' => 'multipart' ]); $file_id = $createdFile->getId(); return redirect('/upload') ->with('message', [ 'type' => 'success', 'text' => "File was uploaded with the following ID: {$file_id}" ]); } catch (Exception $e) { return redirect('/upload') ->with('message', [ 'type' => 'error', 'text' => 'An error occurred while trying to upload the file' ]); } } } public function logout(Request $request) { $request->session()->flush(); return redirect('/')->with('message', ['type' => 'success', 'text' => 'You are now logged out']); } }
Разбивая приведенный выше код, сначала мы создаем две частные переменные для клиента Google и службы Google Drive и инициализируем их в конструкторе.
private $client; private $drive; public function __construct(Googl $googl) { $this->client = $googl->client(); $this->client->setAccessToken(session('user.token')); $this->drive = $googl->drive($this->client); }
Приборная доска
Чтобы создать панель мониторинга, мы добавляем метод index
который обслуживает страницу панели мониторинга:
public function index() { return view('admin.dashboard'); }
Страница панели инструментов содержит ссылки на разные страницы приложения:
@extends('layouts.default') @section('content') <h3>What do you like to do?</h3> <ul> <li><a href="/files">List Files</a></li> <li><a href="/search">Search File</a></li> <li><a href="/upload">Upload File</a></li> <li><a href="/logout">Logout</a></li> </ul> @stop
Список файлов
Далее у нас есть метод files
который отвечает за перечисление файлов, которые были недавно изменены или просмотрены. В этом примере мы фильтруем файлы, которые были изменены или просмотрены за последние три месяца. Мы делаем это, предоставляя запрос на перечисление файлов с некоторыми параметрами.
$three_months_ago = Carbon::now()->subMonths(3)->toRfc3339String(); $parameters = [ 'q' => "viewedByMeTime >= '$three_months_ago' or modifiedTime >= '$three_months_ago'", 'orderBy' => 'modifiedTime', 'fields' => 'nextPageToken, files(id, name, modifiedTime, iconLink, webViewLink, webContentLink)', ];
Большая часть фильтрации может быть выполнена с помощью параметра q
. Здесь мы используем viewedByMeTime
чтобы указать, что файл должен был просматриваться текущим пользователем в течение последних трех месяцев. Мы используем modifiedTime
чтобы указать, что файл должен был быть изменен любым пользователем, имеющим доступ к документу в тот же период. Эти два условия связаны между собой с помощью ключевого слова or
.
Затем результаты можно упорядочить, указав параметр orderBy
. В этом случае мы упорядочиваем файлы, используя modifiedTime
в порядке убывания. Это означает, что файлы, которые были недавно изменены, возвращаются первыми. Также обратите внимание, что мы передаем параметр fields
который используется для указания полей, которые мы хотим вернуть.
Полный список значений, используемых в качестве параметра q
приведен в документации .
Мы также указываем дополнительный параметр с именем pageToken
. Это позволяет нам разбивать на страницы большой набор результатов. Таким образом, пока возвращается токен страницы, мы продолжаем извлекать результаты из API.
if ($pageToken) { $parameters['pageToken'] = $pageToken; }
Для выполнения запроса мы используем сервис Google Диск, чтобы получить список файлов, который соответствует критериям, которые мы указали ранее. Токен страницы также можно получить из результатов.
$result = $this->drive->files->listFiles($parameters); $files = $result->files; $pageToken = $result->getNextPageToken();
Обратите внимание, что все это делается внутри цикла do..while
while. Таким образом, пока токен страницы не является null
он продолжает выполняться.
do { ... } while ($pageToken);
Следующее ( resources/views/admin/files.blade.php
) является представлением, используемым для перечисления файлов:
@extends('layouts.default') @section('content') <h3>List Files</h3> <ul id="files"> @foreach($files as $file) <li> <div class="file"> <div class="file-title"> <img src="{{ $file->iconLink }}"> {{ $file->name }} </div> <div class="file-modified"> last modified: {{ Date::format($file->modifiedTime) }} </div> <div class="file-links"> <a href="{{ $file->webViewLink }}">view</a> @if(!empty($file->webContentLink)) <a href="{{ $file->webContentLink }}">download</a> @endif </div> </div> </li> @endforeach </ul> @stop
Здесь мы перебираем файлы и выводим их в список. Каждый элемент списка содержит значок диска Google, связанный с файлом, заголовок, дату изменения и несколько ссылок для просмотра и загрузки файла. Обратите внимание, что мы используем пользовательский вспомогательный класс с именем Date
. Мы рассмотрим это в разделе « Помощник по датам ».
Поиск файлов
Метод search
отвечает за возврат страницы поиска. Внутри мы проверяем, передан ли запрос как параметр запроса. Затем мы используем это в качестве значения имени файла для поиска. Обратите внимание, что на этот раз мы не nextPageToken
и orderBy
качестве параметров, поскольку мы предполагаем, что в наборе результатов будет только несколько элементов.
if ($request->has('query')) { $query = $request->input('query'); $parameters = [ 'q' => "name contains '$query'", 'fields' => 'files(id, name, modifiedTime, iconLink, webViewLink, webContentLink)', ]; $result = $this->drive->files->listFiles($parameters); if($result){ $files = $result->files; } }
Следующее ( resources/views/admin/search.blade.php
) является соответствующим представлением:
@extends('layouts.default') @section('content') <h3>Search Files</h3> <form method="GET"> <div class="row"> <label for="query">Query</label> <input type="text" name="query" id="query" value="{{ $query }}"> </div> <button class="button-primary">Search</button> </form> @if(!empty($files)) <ul id="files"> <h4>Search results for: {{ $query }}</h4> @foreach($files as $file) <li> <div class="file"> <div class="file-title"> <img src="{{ $file->iconLink }}"> {{ $file->name }} </div> <div class="file-modified"> last modified: {{ Date::format($file->modifiedTime) }} </div> <div class="file-links"> <a href="{{ $file->webViewLink }}">view</a> @if(!empty($webContentLink)) <a href="{{ $file->webContentLink }}">download</a> @endif <a href="/delete/{{ $file->id }}">delete</a> </div> </div> </li> @endforeach </ul> @else No results for your query: {{ $query }} @endif @stop
Удаление файлов
При нажатии на ссылку delete
выполняется метод delete
. Он принимает идентификатор в качестве параметра. Это идентификатор файла на Google Диске.
public function delete($id) { try { $this->drive->files->delete($id); } catch (Exception $e) { return redirect('/search') ->with('message', [ 'type' => 'error', 'text' => 'Something went wrong while trying to delete the file' ]); } return redirect('/search') ->with('message', [ 'type' => 'success', 'text' => 'File was deleted' ]); }
-public function delete($id) { try { $this->drive->files->delete($id); } catch (Exception $e) { return redirect('/search') ->with('message', [ 'type' => 'error', 'text' => 'Something went wrong while trying to delete the file' ]); } return redirect('/search') ->with('message', [ 'type' => 'success', 'text' => 'File was deleted' ]); }
Загрузка файлов
Загрузка файлов требует другого метода для отображения представления и фактической загрузки файла. Это потому, что нам нужно использовать метод POST
для загрузки файла и метод GET
для отображения представления.
Представление загрузки ( resources/views/admin/upload.blade.php
) содержит форму для загрузки одного файла:
@extends('layouts.default') @section('content') <h3>Upload Files</h3> <form method="POST" enctype="multipart/form-data"> <input type="hidden" name="_token" value="{{ csrf_token() }}"> <div class="row"> <label for="file">File</label> <input type="file" name="file" id="file"> </div> <div class="row"> <label for="description">Description</label> <input type="text" name="description" id="description"> </div> <button class="button-primary">Upload</button> </form> @stop
Как только форма отправлена, doUpload
метод doUpload
. Это проверяет, был ли загружен файл. Затем мы получаем файл и его информацию (тип mime, имя файла). Эта информация затем используется для создания нового файла Google Drive.
if ($request->hasFile('file')) { $file = $request->file('file'); $mime_type = $file->getMimeType(); $title = $file->getClientOriginalName(); $description = $request->input('description'); $drive_file = new \Google_Service_Drive_DriveFile(); $drive_file->setName($title); $drive_file->setDescription($description); $drive_file->setMimeType($mime_type); }
Затем мы загружаем файл на диск Google, используя метод files->create
. Это принимает файл диска в качестве первого аргумента и массив, содержащий фактическое содержимое файла, тип mime и тип загрузки в качестве второго аргумента. Тип загрузки может иметь значение media
, multipart
или resumable
. Вы можете проверить документацию для загрузки, если вы хотите изучить эти типы загрузки. Однако в нашем примере мы будем использовать только multipart
так как мы напрямую загружаем файлы в Google Drive, отправляя форму. Метод create
возвращает идентификатор файла, если загрузка прошла успешно.
try { $createdFile = $this->drive->files->create($drive_file, [ 'data' => $file, 'mimeType' => $mime_type, 'uploadType' => 'multipart' ]); $file_id = $createdFile->getId(); return redirect('/upload') ->with('message', [ 'type' => 'success', 'text' => "File was uploaded with the following ID: {$file_id}" ]); } catch (Exception $e) { return redirect('/upload') ->with('message', [ 'type' => 'error', 'text' => 'An error occured while trying to upload the file' ]); }
Date Helper
Помощник по Date
используется для форматирования дат, возвращаемых API Google Диска. Здесь у нас есть только один статический метод с именем format
который принимает два аргумента: date и format. Дата обязательна, и для формата мы указываем значение по умолчанию: M j
, которое выводит дату в следующем формате: 28 февраля . Углерод используется для форматирования.
<?php use Carbon\Carbon; class Date { public static function format($date, $format = 'M j'){ return Carbon::parse($date)->format($format); } }
Для автозагрузки помощника нам нужно добавить путь app/Helpers
в массив classmap
в файле composer.json
. Как только это будет сделано, выполнение composer dump-autoload
применит изменения.
"autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/", "app/Helpers" ] },
Вывод
Это оно! В этом уроке мы увидели, как работать с Google Drive API с помощью PHP. В частности, мы рассмотрели, как составлять список, искать, загружать и удалять файлы с пользовательского диска Google.
Мы едва поцарапали поверхность в этом уроке. Вы можете сделать гораздо больше: есть ревизии файлов и возможности в режиме реального времени . Не забудьте проверить документы, если вы хотите узнать больше.
Вопросов? Комментарии? Оставьте свой отзыв ниже!