Статьи

Можем ли мы использовать Laravel для создания пользовательского интерфейса Google Drive?

В этом руководстве мы собираемся создать приложение, которое взаимодействует с Google Drive API. Он будет иметь функции поиска файлов, загрузки, скачивания и удаления. Если вы хотите следовать, вы можете клонировать репо из Github .

Логотип Google Диска

Создание нового проекта 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.

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

Вопросов? Комментарии? Оставьте свой отзыв ниже!