Предисловие
Мое веб-приложение хранит данные в
localStorage
. Это было удобно, пока не захотелось, чтобы пользователь, заходя на сайт с разных устройств, видел одно и то же. То есть, понадобилось удаленное хранилище.Но приложение «хостится» на GitHub Pages и не имеет серверной части. Я решил не делать сервер, а данные хранить у третьей стороны. Это дает существенные преимущества:
- Не нужно платить за сервер, не болит голова о его стабильности и доступности.
- Меньше кода, меньше ошибок.
- Пользователю не нужно регистрироваться в моем приложении (это многих раздражает).
- Приватность выше, и пользователь знает, что его данные хранятся в месте, которому он, скорее всего, доверяет больше, чем мне.
Сначала выбор пал на remoteStorage.js. Они предлагают открытый протокол обмена данными, достаточно приятное API, возможность интеграции с Google Drive и Dropbox, а также свои сервера. Но этот путь оказался тупиковым (почему — отдельная история).
В итоге решил использовать Google Drive напрямую, и Google API Client Library (далее GAPI) как библиотеку для доступа к нему.
К сожалению, документация Google разочаровывает, а библиотека GAPI выглядит недоработанной, к тому же имеет несколько версий, и не всегда понятно, о какой из них идет речь. Поэтому решение моих задач пришлось собирать по кусочкам из документации, вопросов и ответов на StackOverflow и случайных постов в интернете.
Надеюсь, данная статья сэкономит вам время, если вы решите использовать Google Drive в вашем приложении.
Подготовка
Далее идет описание получения ключей для работы с Google API. Если вам это неинтересно, переходите сразу к следующей части.
Получение ключей
В Google Developer Console создаем новый проект, вводим имя.
В «Панели управления» нажимаем «Включить API и сервисы» и включаем Google Drive.
Далее переходим в раздел API и Сервисы -> Учетные данные, нажимаем «Создание учетных данных». Там нужно сделать три вещи:
Раздел «Учетные данные» должен выглядеть примерно так:
Здесь мы закончили. Переходим к коду.
В «Панели управления» нажимаем «Включить API и сервисы» и включаем Google Drive.
Далее переходим в раздел API и Сервисы -> Учетные данные, нажимаем «Создание учетных данных». Там нужно сделать три вещи:
- Настроить «Окно запроса доступа OAuth». Вводим название приложения, свой домен в разделе «Авторизованные домены» и ссылку на главную страницу приложения. Другие поля заполняем по желанию.
- В разделе «Учетные данные» нажимаем «Создать учетные данные» -> «Идентификатор клиента OAuth». Выбираем тип «Веб-приложение». В окне настроек нужно добавить «Разрешенные источники Javascript» и «Разрешенные URI перенаправления»:
- Ваш домен (обязательно)
http://localhost:8000
(по желанию, чтобы работало локально).
- Ваш домен (обязательно)
- В разделе «Учетные данные» нажимаем «Создать учетные данные» -> «Ключ API». В настройках ключа указываем ограничения:
- Допустимый тип приложений -> HTTP-источники перехода (веб-сайты)
- Принимать http-запросы от следующих источников перехода (сайтов) -> ваш домен и localhost (как и в пункте 2).
- Допустимые API -> Google Drive API
- Допустимый тип приложений -> HTTP-источники перехода (веб-сайты)
Раздел «Учетные данные» должен выглядеть примерно так:
Здесь мы закончили. Переходим к коду.
Инициализация и логин
Рекомендованный 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(onSignIn)
// инициализация приложения
initApp()
}, error => {
console.log('Failed to init GAPI client', error)
// работаем без гугла
initApp({showAlert: 'google-init-failed-alert'})
})
}
Для хранения данных мы будем использовать так называемую Application Data folder. Ее преимущества перед обычной папкой:
- Пользователь не видит ее напрямую: файлы из нее не засоряют его личное пространство, и он не может испортить наши данные.
- Другие приложения ее не видят и тоже не могут испортить.
- Scope, указанный выше, дает приложению доступ к ней, но не дает доступа к остальным файлам пользователя. То есть, мы не отпугнем человека запросами на доступ к его личным данным.
При успешной инициализации Google API функция делает следующее:
- Начинает ловить события логина/логаута — скорее всего, это нужно делать всегда.
- Инициализирует приложение. Это можно делать до загрузки и инициализации 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 {
// пользователь вышел
}
// пример реализации см. ниже в разделе "Синхронизация"
}
К сожалению, работа с файлами не так очевидна.
Promise helper
GAPI не возвращает нормальных Promise’ов. Вместо этого, используется собственный интерфейс Thennable, который похож на промисы, но не совсем. Поэтому для удобства работы (главным образом, чтобы использовать
async/await
), сделаем небольшой хелпер: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 Drive не является полноценной базой данных. Например, если вам нужно, чтобы несколько пользователей работали из-под одного Google-аккаунта одновременно с разных устройств, могут возникнуть проблемы с разрешением конфликтов из-за отсутствия транзакций. Для таких задач лучше не использовать 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
), отсортированные по времени создания.При указании строки
query
(синтаксис описан здесь) она вернет только файлы, удовлетворяющие запросу. Например, чтобы проверить, существует ли файл с именем config.json
, нужно сделать 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
.Ниже предложена простая стратегия синхронизации конфигурации:
- Новая конфигурация скачивается с Google Drive при логине, и затем каждые 3 минуты, перезаписывая локальную копию;
- Локальные изменения заливаются на Google Drive, перезаписывая то, что там было;
fileID
конфигурации кэшируется вlocalStorage
для ускорения работы и уменьшения количества запросов;- Корректно обрабатываются (ошибочные) ситуации, когда на Google Drive есть несколько файлов конфигураци, и когда кто-то удалил наш файл конфигурациию или испортил его.
- Детали синхронизации не влияют на остальной код приложения. Для работы с конфигурацией вы используете только две функции:
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 отлично подоходит для небольших проектов и прототипирования. Оно не только просто в реализации и поддержке, но и способствует уменьшению количества ненужных сущностей во Вселенной. А моя статья, надеюсь, поможет вам сэкономить время, если вы выберете этот путь.
P.S. Код реального проекта лежит на GitHub, попробовать его можно здесь.