Хабрахабр

Google Drive как хранилище для веб-приложения

Мое веб-приложение хранит данные в localStorage. Это было удобно, пока не захотелось, чтобы пользователь, заходя на сайт с разных устройств, видел одно и то же. То есть, понадобилось удаленное хранилище.

Я решил не делать сервер, а данные хранить у третьей стороны. Но приложение «хостится» на GitHub Pages и не имеет серверной части. Это дает существенные преимущества:

  1. Не нужно платить за сервер, не болит голова о его стабильности и доступности.
  2. Меньше кода, меньше ошибок.
  3. Пользователю не нужно регистрироваться в моем приложении (это многих раздражает).
  4. Приватность выше, и пользователь знает, что его данные хранятся в месте, которому он, скорее всего, доверяет больше, чем мне.

Сначала выбор пал на remoteStorage.js. Они предлагают открытый протокол обмена данными, достаточно приятное API, возможность интеграции с Google Drive и Dropbox, а также свои сервера. Но этот путь оказался тупиковым (почему — отдельная история).

В итоге решил использовать Google Drive напрямую, и Google API Client Library (далее GAPI) как библиотеку для доступа к нему.

Поэтому решение моих задач пришлось собирать по кусочкам из документации, вопросов и ответов на StackOverflow и случайных постов в интернете. К сожалению, документация Google разочаровывает, а библиотека GAPI выглядит недоработанной, к тому же имеет несколько версий, и не всегда понятно, о какой из них идет речь.

Надеюсь, данная статья сэкономит вам время, если вы решите использовать Google Drive в вашем приложении.

Далее идет описание получения ключей для работы с Google API. Если вам это неинтересно, переходите сразу к следующей части.

Получение ключей

В Google Developer Console создаем новый проект, вводим имя.

В «Панели управления» нажимаем «Включить API и сервисы» и включаем Google Drive.

Там нужно сделать три вещи: Далее переходим в раздел API и Сервисы -> Учетные данные, нажимаем «Создание учетных данных».

  1. Настроить «Окно запроса доступа OAuth». Вводим название приложения, свой домен в разделе «Авторизованные домены» и ссылку на главную страницу приложения. Другие поля заполняем по желанию.
  2. В разделе «Учетные данные» нажимаем «Создать учетные данные» -> «Идентификатор клиента OAuth». Выбираем тип «Веб-приложение». В окне настроек нужно добавить «Разрешенные источники Javascript» и «Разрешенные URI перенаправления»:
    • Ваш домен (обязательно)
    • http://localhost:8000 (по желанию, чтобы работало локально).

  3. В разделе «Учетные данные» нажимаем «Создать учетные данные» -> «Ключ API». В настройках ключа указываем ограничения:
    • Допустимый тип приложений -> HTTP-источники перехода (веб-сайты)
    • Принимать http-запросы от следующих источников перехода (сайтов) -> ваш домен и localhost (как и в пункте 2).
    • Допустимые API -> Google Drive API

Раздел «Учетные данные» должен выглядеть примерно так:

Переходим к коду.
Здесь мы закончили.

Рекомендованный Google способ подключения GAPI — вставить следующий код в свой HTML:

<script src="https://apis.google.com/js/api.js" onload="this.onload=function(); gapi.load('client:auth2', initClient)" onreadystatechange="if (this.readyState === 'complete') this.onload()">
</script>

После загрузки библиотеки будет вызвана функция initClient, которую мы должны написать сами. Типичный ее вид таков:

function initClient() { gapi.client.init({ // Ваш ключ API apiKey: GOOGLE_API_KEY, // Ваш идентификатор клиента clientId: GOOGLE_CLIENT_ID, // Указание, что мы хотим использовать Google Drive API v3 discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'], // Запрос доступа к application data folder (см. ниже) scope: 'https://www.googleapis.com/auth/drive.appfolder' }).then(() => { // Начинаем ловить события логина/логаута (см. ниже) gapi.auth2.getAuthInstance().isSignedIn.listen(onSingIn) // инициализация приложения initApp() }, error => { console.log('Failed to init GAPI client', error) // работаем без гугла initApp({showAlert: 'google-init-failed-alert'}) })
}

Для хранения данных мы будем использовать так называемую Application Data folder. Ее преимущества перед обычной папкой:

  1. Пользователь не видит ее напрямую: файлы из нее не засоряют его личное пространство, и он не может испортить наши данные.
  2. Другие приложения ее не видят и тоже не могут испортить.
  3. Scope, указанный выше, дает приложению доступ к ней, но не дает доступа к остальным файлам пользователя. То есть, мы не отпугнем человека запросами на доступ к его личным данным.

При успешной инициализации Google API функция делает следующее:

  1. Начинает ловить события логина/логаута — скорее всего, это нужно делать всегда.
  2. Инициализирует приложение. Это можно делать до загрузки и инициализации GAPI — как вам удобнее. У меня процедура инициализации несколько отличалась в случае, если Google недоступен. Кто-то может сказать, что такого не бывает 🙂 Но, во-первых, вы можете намудрить с ключами и правами доступа в будущем. Во-вторых, например, в Китае Google забанен.

Логин и логаут делаются просто:

function isGapiLoaded() { return gapi && gapi.auth2
} function logIn() { if (isGapiLoaded()) { // откроется стандартное окно Google с выбором аккаунта gapi.auth2.getAuthInstance().signIn() }
} function logOut() { if (isGapiLoaded()) { gapi.auth2.getAuthInstance().signOut() }
}

Результаты логина получите в обработчике onSignIn:

function isLoggedIn() { return isGapiLoaded() && gapi.auth2.getAuthInstance().isSignedIn.get()
} function onSignIn() { if (isLoggedIn()) { // пользователь зашел } else { // пользователь вышел } // пример реализации см. ниже в разделе "Синхронизация"
}

К сожалению, работа с файлами не так очевидна.
GAPI не возвращает нормальных Promise’ов. Вместо этого, используется собственный интерфейс Thennable, который похож на промисы, но не совсем. Поэтому для удобства работы (главным образом, чтобы использовать async/await), сделаем небольшой хелпер:

async function prom(gapiCall, argObj) { return new Promise((resolve, reject) => { gapiCall(argObj).then(resp => { if (resp && (resp.status < 200 || resp.status > 299)) { console.log('GAPI call returned bad status', resp) reject(resp) } else { resolve(resp) } }, err => { console.log('GAPI call failed', err) reject(err) }) })
}

Эта функция принимает первым аргументом метод GAPI и параметры к нему и возвращает Promise. Дальше будет видно, как ее использовать.
Нужно всегда помнить, что имя файла на Google Drive не является уникальным. Можно создавать сколько угодно файлов и папок с одинаковыми именами. Уникальным является только идентификатор.

В комментариях указано, что нужно изменить для работы с папками. Для базовых задач не нужна работа с папками, поэтому все функции ниже работают с файлами в корне Application Data folder. Документация от Google здесь.

Создание пустого файла

async function createEmptyFile(name, mimeType) { const resp = await prom(gapi.client.drive.files.create, { resource: { name: name, // для создания папки используйте // mimeType = 'application/vnd.google-apps.folder' mimeType: mimeType || 'text/plain', // вместо 'appDataFolder' можно использовать ID папки parents: ['appDataFolder'] }, fields: 'id' }) // функция возвращает строку — идентификатор нового файла return resp.result.id
}

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

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

Работа с содержимым файлов

GAPI (для браузерного JavaScript) не предоставляет методов работы с содержимым файлов (очень странно, не правда ли?). Вместо этого есть общий метод request (тонкая обертка над простым AJAX-запросом).

Методом проб и ошибок я пришел к следующим реализациям:

async function upload(fileId, content) { // функция принимает либо строку, либо объект, который можно сериализовать в JSON return prom(gapi.client.request, { path: `/upload/drive/v3/files/${fileId}`, method: 'PATCH', params: {uploadType: 'media'}, body: typeof content === 'string' ? content : JSON.stringify(content) })
} async function download(fileId) { const resp = await prom(gapi.client.drive.files.get, { fileId: fileId, alt: 'media' }) // resp.body хранит ответ в виде строки // resp.result — это попытка интерпретировать resp.body как JSON. // Если она провалилась, значение resp.result будет false // Т.о. функция возвращает либо объект, либо строку return resp.result || resp.body
}

Поиск файлов

async function find(query) { let ret = [] let token do { const resp = await prom(gapi.client.drive.files.list, { // вместо 'appDataFolder' можно использовать ID папки spaces: 'appDataFolder', fields: 'files(id, name), nextPageToken', pageSize: 100, pageToken: token, orderBy: 'createdTime', q: query }) ret = ret.concat(resp.result.files) token = resp.result.nextPageToken } while (token) // результат: массив объектов вида [{id: '...', name: '...'}], // отсортированных по времени создания return ret
}

Эта функция, если не указывать query, возвращает все файлы в папке приложения (массив объектов с полями id и name), отсортированные по времени создания.

Например, чтобы проверить, существует ли файл с именем config.json, нужно сделать При указании строки query (синтаксис описан здесь) она вернет только файлы, удовлетворяющие запросу.

if ((await find(‘name = "config.json"’)).length > 0) { // файл(ы) существует }

Удаление файлов

async function deleteFile(fileId) { try { await prom(gapi.client.drive.files.delete, { fileId: fileId }) return true } catch (err) { if (err.status === 404) { return false } throw err }
}

Эта функция удаляет файл по ID и возвращает true, если он успешно удален, и false, если такого файла не было.
Желательно, чтобы программа работала в первую очередь с localStorage, а Google Drive использовался только для синхронизации данных из localStorage.

Ниже предложена простая стратегия синхронизации конфигурации:

  1. Новая конфигурация скачивается с Google Drive при логине, и затем каждые 3 минуты, перезаписывая локальную копию;
  2. Локальные изменения заливаются на Google Drive, перезаписывая то, что там было;
  3. fileID конфигурации кэшируется в localStorage для ускорения работы и уменьшения количества запросов;
  4. Корректно обрабатываются (ошибочные) ситуации, когда на Google Drive есть несколько файлов конфигураци, и когда кто-то удалил наш файл конфигурациию или испортил его.
  5. Детали синхронизации не влияют на остальной код приложения. Для работы с конфигурацией вы используете только две функции: getConfig() и saveConfig(newConfig).

В реальном приложении вы, вероятно, захотите реализовать более гибкую обработку конфликтов при загрузке/выгрузке конфигурации.

Посмотреть код

// Интервал между синхронизациями конфига
const SYNC_PERIOD = 1000 * 60 * 3 // 3 минуты
// Конфигурация по умолчанию
const DEFAULT_CONFIG = { // ...
} // храним ID таймера синхронизации, чтобы иметь возможность его сбросить
let configSyncTimeoutId async function getConfigFileId() { // берем configFileId let configFileId = localStorage.getItem('configFileId') if (!configFileId) { // ищем нужный файл на Google Drive const configFiles = await find('name = "config.json"') if (configFiles.length > 0) { // берем первый (раньше всех созданный) файл configFileId = configFiles[0].id } else { // создаем новый configFileId = await createEmptyFile('config.json') } // сохраняем ID localStorage.setItem('configFileId', configFileId) } return configFileId
} async function onSignIn() { // обработчик события логина/логаута (см. выше) if (isLoggedIn()) { // пользователь зашел // шедулим (как это по-русски?) немедленную синхронизацию конфига scheduleConfigSync(0) } else { // пользователь вышел // в следующий раз пользователь может зайти под другим аккаунтом // поэтому забываем config file ID localStorage.removeItem('configFileId') // в localStorage лежит актуальный конфиг, дальше пользуемся им }
} function getConfig() { let ret try { ret = JSON.parse(localStorage.getItem('config')) } catch(e) {} // если сохраненного конфига нет, возвращаем копию дефолтного return ret || {...DEFAULT_CONFIG}
} async function saveConfig(newConfig) { // эту функцию зовем всегда, когда надо изменить конфиг localStorage.setItem('config', JSON.stringify(newConfig)) if (isLoggedIn()) { // получаем config file ID const configFileId = await getConfigFileId() // заливаем новый конфиг в Google Drive upload(configFileId, newConfig) }
} async function syncConfig() { if (!isLoggedIn()) { return } // получаем config file ID const configFileId = await getConfigFileId() try { // загружаем конфиг const remoteConfig = await download(configFileId) if (!remoteConfig || typeof remoteConfig !== 'object') { // пустой или испорченный конфиг, перезаписываем текущим upload(configFileId, getConfig()) } else { // сохраняем локально, перезаписывая существующие данные localStorage.setItem('config', JSON.stringify(remoteConfig)) } // синхронизация завершена, в localStorage актуальный конфиг } catch(e) { if (e.status === 404) { // кто-то удалил наш конфиг, забываем неверный fileID и пробуем еще раз localStorage.removeItem('configFileId') syncConfig() } else { throw e } }
} function scheduleConfigSync(delay) { // сбрасываем старый таймер, если он был if (configSyncTimeoutId) { clearTimeout(configSyncTimeoutId) } configSyncTimeoutId = setTimeout(() => { // выполняем синхронизацию и шедулим снова syncConfig() .catch(e => console.log('Failed to synchronize config', e)) .finally(() => scheduleSourcesSync()) }, typeof delay === 'undefined' ? SYNC_PERIOD : delay)
} function initApp() { // запускаем синхронизацию при старте приложения scheduleConfigSync()
}

Мне кажется, хранилище данных для веб-сайта на Google Drive отлично подоходит для небольших проектов и прототипирования. Оно не только просто в реализации и поддержке, но и способствует уменьшению количества ненужных сущностей во Вселенной. А моя статья, надеюсь, поможет вам сэкономить время, если вы выберете этот путь.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть