Предисловие


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

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

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

Сначала выбор пал на 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 и Сервисы -> Учетные данные, нажимаем «Создание учетных данных». Там нужно сделать три вещи:

  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 {
        // пользователь вышел
    }
    // пример реализации см. ниже в разделе "Синхронизация"
}

К сожалению, работа с файлами не так очевидна.

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.

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

  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 отлично подоходит для небольших проектов и прототипирования. Оно не только просто в реализации и поддержке, но и способствует уменьшению количества ненужных сущностей во Вселенной. А моя статья, надеюсь, поможет вам сэкономить время, если вы выберете этот путь.

P.S. Код реального проекта лежит на GitHub, попробовать его можно здесь.

Комментарии (19)


  1. lostmsu
    21.02.2019 10:48

    Использовал именно эту технику для своей static single-page читалки EPub с синхронизацией между устройствами: h5reader.azurewebsites.net (писал для себя, так что интерфейс не ахти и из фич только чтение текста и синхронизация).

    Основная проблема была — когда гугл ломал SDK в Edge/IE, никто не спешил его чинить… Так что может имеет смысл какой-нибудь другой компании облако использовать.


  1. DeXPeriX
    21.02.2019 11:53

    Не совсем понял, оно каждого пользователя просит залогиниться в его собственный Google Drive? Или работает просто как бэкэнд и абсолютно невидимо для пользователя? Если второе, то используется аккуант разработчика? Как тогда разграничивать где чьи данные? Был бы очень рад увидеть пример реального использования.


    1. redmanmale
      21.02.2019 14:28

      Первое. Пользователь логинится в свой Google акк, и приложение хранит свои данные на пользовательском G-диске.


    1. Valfman
      21.02.2019 15:03

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


      1. dsavenko Автор
        21.02.2019 15:09

        Именно так.


    1. dsavenko Автор
      21.02.2019 15:08

      Да, как уже заметили другие, данные каждого пользователя хранятся на его собственном Гугл-диске. То есть, у пользователя должен быть свой гугл-аккаунт.

      Я бы хотел дать пример реального использования. Но, если я правильно понял правила Хабра, это было бы расценено как реклама собственного проекта.


      1. DeXPeriX
        21.02.2019 15:12

        Ну в комментариях ссылку уж точно можно :-)


        Одно дело когда почти весь текст "купи! по ссылке" — это будет реклама. Другое дело, когда у вас однократно встретится "код реального проекта на гитхабе смотреть здесь, посмотреть вживую тут". Такое даже в основной текст внести можно.


        1. dsavenko Автор
          22.02.2019 13:15

          Добавил ссылки в статью. Попробовать проект можно здесь, а код лежит там.


      1. JanisV
        21.02.2019 15:29

        По новым правилам свои проекты можно рекламировать (в разумных пределах)


        1. dsavenko Автор
          22.02.2019 13:16

          Спасибо за наводку.


  1. k12th
    21.02.2019 12:18
    +1

    Огромное спасибо за статью!


    А всё-таки, что было не так с remoteStorage?


    1. dsavenko Автор
      21.02.2019 15:41
      +1

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

      1. Собственный хостинг работает очень плохо. Точнее, сам remoteStorage не предоставляет хостинга, а рекомендует пользоваться сервисами третьих сторон, которые установили их (открытое) серверное ПО. Сейчас из бесплатных вариантов доступен только один: 5apps. Я просидел на нем около пары недель, и даже за такое короткое время успел устать от постоянных проблем (непредсказуемо тормозит, часто недоступен).
      2. Интеграция с Гуглом оставляет желать лучшего. Проблема, которая меня «добила»: каждый час надо «перелогиниваться» (то есть снова открывается окно Гугла). Это было совершенно неприемлемо. Я завел тикет на Гитхабе, на что получил ответ, что других вариантов нет (это не так). Такое поведение библиотеки серьезно воспринимать нельзя, это просто издевательство над пользователем.
      3. remoteStorage.js не дает гибкого управления синхронизацией. Например, мое приложение хранит много очень маленьких файлов на Google Drive, и в определенные моменты времени мне нужно быстро взять определенный файл. Но поскольку я не контролирую порядок, в котором они синхронизируются (а делается это по одному файлу за раз), актуальное состояние данного конкретного файла я могу получить только через 5-10-15 минут (а могу и сразу, это как повезет)
      4. remoteStorage.js не использует application data folder, и вместо этого создает файлы в видимом для пользователя пространстве (папка remotestorage в корне вашего гугл-диска, а в ней папка с названием вашего проекта). Это очень неудобно, особенно если файлов много.
      5. remoteStorage.js иногда создает две папки remotestorage, внутри которых по две папки вашего проекта. Видимо, какие-то баги. Меня это раздражало.



      1. k12th
        21.02.2019 15:43

        Звучит кошмарно. Спасибо.


  1. vlanko
    21.02.2019 14:56

    del


  1. caffeinum
    21.02.2019 15:57

    На гитхабе есть проект с похожей штукой, но там это только для админа, а не для всех пользователей: https://github.com/misterfresh/react-drive-cms.

    Тоже Github Pages, тоже Google Drive. Еще интересно, что у него в этом репо Реакт работает без Webpack (я так и нашел его).

    В общем, оставляю ссылку, может кому-то интересно будет)


    1. justboris
      21.02.2019 23:34

      Еще интересно, что у него в этом репо Реакт работает без Webpack (я так и нашел его).

      Мне тоже стало интересно, покопал. Магия находится вот в этом файле. В проекте используется сервер, который процессит файлы через babel на лету перед отдачей пользователю


  1. pan-alexey
    21.02.2019 16:35

    del


  1. myemuk
    22.02.2019 13:48
    +1

    Не обязательно объявлять функцию prom асинхронной. Она никакую асинхронную работу не выполняет, только сразу возвращает Promise.


    1. dsavenko Автор
      22.02.2019 14:11

      Спасибо! Поправил.