65.9K
CodeProject 正在变化。 阅读更多。
Home

NodeJs Google 云端硬盘备份

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (6投票s)

2015年10月22日

CPOL

7分钟阅读

viewsIcon

25393

downloadIcon

388

使用 Google 云端硬盘 JavaScript API 和 NodeJs 备份重要文件和文件夹

引言

本文将向您展示如何编写和运行一个简单的应用程序,该应用程序使用 NodeJs 和最新的 Google 云端硬盘 JavaScript SDK 将本地文件夹与远程 Google 云端硬盘文件夹同步。

背景

我希望阻止特定子文件夹(隐藏的 .svn 文件夹)被备份。由于适用于 Windows 的 Google 云端硬盘没有此功能,我决定使用 Google 云端硬盘 API 编写自己的解决方案。

NodeJs 应用程序

我们的 NodeJs 应用程序结构非常简单,它包含:

  • 顶部的 imports 部分,
  • 接着是主方法调用,
  • 最后是主方法定义。
// imports
var fs = require('fs');
var readline = require('readline');
var google = require('googleapis');
var OAuth2 = google.auth.OAuth2;
var path = require('path');
var crypto = require('crypto');
var util = require('util');

// main invocation
syncFolder('C:/tmp/drive123', 'dev2/test4');

// main method definition
function syncFolder(localFolderPath, remoteFolderPath) {

    const CREDENTIALS_FILE = "client_secret.json";
    const AUTH_FILE = "auth.json";
    const FOLDER_MIME = "application/vnd.google-apps.folder";

    var drive;

    loadCredentials(function () {
        createRemoteBaseHierarchy('root', function (folderId) {
            syncLocalFolderWithRemoteFolderId(localFolderPath, folderId);
        });
    });

    [...]
}

主方法有 3 个步骤:

  1. loadCredentials - 检索 Google API 访问令牌并根据需要生成授权 URL
  2. createRemoteBaseHierarchy - 创建 Google 云端硬盘远程文件夹基本层次结构
  3. syncLocalFolderWithRemoteFolderId - 递归同步本地文件夹与远程文件夹

loadCredentials() 中,我们首先加载包含 client_idclient_secret 的文件 client_secret.json(从您的 Google API 帐户下载 - 请参阅末尾的初始设置)。然后我们尝试加载包含您 Google 云端硬盘帐户访问令牌的文件 auth.json。如果找不到 auth 文件,系统会提示用户授权访问云端硬盘并在提示符下输入代码。收到的令牌会保存在磁盘上的 auth.json 中。如果找到该文件,我们初始化 Google 云端硬盘 API 凭据并进入下一步。

createRemoteBaseHierarchy() 中,我们确保完整的远程 Google 云端硬盘文件夹层次结构存在。如果不存在,我们根据需要创建文件夹以匹配路径。最后一个/最底层的文件夹的 ID 将用作第三步的输入。

最后,在 syncLocalFolderWithRemoteFolderId() 中,我们扫描本地和远程文件夹,并确保本地文件夹中的所有文件都以递归方式存在于远程文件夹中。我们使用文件的 md5checksum 来检测现有文件的更改。

正如您在源代码中看到的,这 3 个函数使用回调函数进行链式调用,因为 Google API 函数都是异步运行的。如果您尝试像这样按顺序调用这 3 个函数:

loadCredentials();
createRemoteBaseHierarchy('root', remoteFolderPath);
syncLocalFolderWithRemoteFolderId(localFolderPath, folderId);

这将不起作用,因为 loadCredentials() 中的凭据读取发生在 fs.readFile() 的回调函数中,而不是在 loadCredentials() 执行返回时。

为了更好地理解这一点,让我们看看 loadCredentials()

function loadCredentials(callback) {
        fs.readFile(CREDENTIALS_FILE, function (err, content) {
            if (err) { console.log('File', 
            CREDENTIALS_FILE, 'not found in (', __dirname, ').'); return; }

            var clientSecret = JSON.parse(content);
            var keys = clientSecret.web || clientSecret.installed;
            var oauth2Client = new OAuth2(keys.client_id, keys.client_secret, keys.redirect_uris[0]);

            // inititializes the google drive api
            drive = google.drive({ version: 'v2', auth: oauth2Client });

            fs.readFile(AUTH_FILE, function (err, content) {
                if (err)
                    fetchGoogleAuthorizationTokens(oauth2Client);

                else {
                    oauth2Client.credentials = JSON.parse(content);
                    callback();
                }
            });
        });
    }

回调函数参数仅在异步读取 CREDENTIALS_FILEAUTH_FILE 文件之后才会被调用。

createRemoteBaseHierarchy() 中使用了类似的逻辑

function createRemoteBaseHierarchy(parentId, callback) {
        var folderSegments = remoteFolderPath.split('/');

        var createSingleRemoteFolder = function (parentId) {
            var remoteFolderName = folderSegments.shift();

            if (remoteFolderName === undefined)
                // done processing folder segments - start the folder syncing job
                callback(parentId);

            else {
                var query = "(mimeType='" + FOLDER_MIME + 
                "') and (trashed=false) and (title='" 
                           + remoteFolderName + "') and 
					('" + parentId + "' in parents)";

                drive.files.list({
                    maxResults: 1,
                    q: query
                }, function (err, response) {
                    if (err) { console.log('The API returned an error: ' + err); return; }

                    if (response.items.length === 1) {
                        // folder segment already exists, keep going down...
                        var folderId = response.items[0].id;
                        createSingleRemoteFolder(folderId);

                    } else {
                        // folder segment does not exist, create the remote folder and keep going down...
                        drive.files.insert({
                            resource: {
                                title: remoteFolderName,
                                parents: [{ "id": parentId }],
                                mimeType: FOLDER_MIME
                            }
                        }, function (err, response) {
                            if (err) { console.log('The API returned an error: ' + err); return; }

                            var folderId = response.id;
                            console.log('+ /%s', remoteFolderName);
                            createSingleRemoteFolder(folderId);
                        });
                    }
                });
            }
        };

        createSingleRemoteFolder(parentId);
    }

createRemoteBaseHierarchy 递归读取或创建 Google 云端硬盘上的文件夹,直到到达最后一个文件夹段。它包含一个内部函数 createSingleRemoteFolder,用于处理(读取或创建)特定 folderId 下的特定文件夹段。内部函数会递归调用,直到到达最后一个文件夹段。当发生这种情况时,回调函数将使用该最后一个段的 folderId 进行调用。

所以有了

syncFolder('C:/tmp/drive123', 'dev2/test4');

createRemoteBaseHierarchy 将确保您的 Google 云端硬盘的根文件夹中有一个名为 dev2 的文件夹。然后它将检查 dev2 下是否存在名为 test4 的文件夹。回调将与 test4 一起调用。

让我们看看链中的最后一个方法:syncLocalFolderWithRemoteFolderId。请记住,当调用该函数时,Google 云端硬盘凭据是有效的,基本文件夹已创建(dev2/test4),并且我们知道最后一个文件夹段(test4)的 folderId

function syncLocalFolderWithRemoteFolderId(localFolderPath, remoteFolderId) {
        retrieveAllItemsInFolder(remoteFolderId, function (remoteFolderItems) {
            processRemoteItemList(localFolderPath, remoteFolderId, remoteFolderItems);
        });
    }

第一步是 retrieveAllItemsInFolder,它获取 remoteFolderId (test4) 下的所有项(文件和文件夹)。当完整列表填充后,我们调用 processRemoteItemList,它将根据需要创建/更新/删除 Google 云端硬盘中的项,并递归查看子文件夹。

这是 retrieveAllItemsInFolder 的代码

function retrieveAllItemsInFolder(remoteFolderId, callback) {
        var query = "(trashed=false) and ('" + 
        	remoteFolderId + "' in parents)";

        var retrieveSinglePageOfItems = function (items, nextPageToken) {
            var params = { q: query };
            if (nextPageToken)
                params.pageToken = nextPageToken;

            drive.files.list(params, function (err, response) {
                if (err) {
                    invokeLater(err, function () {
                        retrieveAllItemsInFolder(remoteFolderId, callback);
                    });
                    return;
                }

                items = items.concat(response.items);
                var nextPageToken = response.nextPageToken;

                if (nextPageToken)
                    retrieveSinglePageOfItems(items, nextPageToken);

                else
                    callback(items);
            });
        }

        retrieveSinglePageOfItems([]);
    }

内部函数 retrieveSinglePageOfItems 用于检索 remoteFolderId 下的所有项。由于 Google 云端硬盘 API 限制了一次返回的项数(100+),因此内部函数可能会被多次调用,直到返回所有项。当没有更多 'nextPage' 时,回调函数将以完全填充的项列表作为参数调用。

最后,让我们看看最后一个有趣的函数,processRemoteItemList

function processRemoteItemList(localFolderPath, remoteFolderId, remoteFolderItems) {
        var remoteItemsToRemoveByIndex = [];
        for (var i = 0; i < remoteFolderItems.length; i++)
            remoteItemsToRemoveByIndex.push(i);

        // lists files and folders in localFolderPath
        fs.readdirSync(localFolderPath).forEach(function (localItemName) {
            var localItemFullPath = path.join(localFolderPath, localItemName);
            var stat = fs.statSync(localItemFullPath);

            var buffer;
            if (stat.isFile())
                // if local item is a file, puts its contents in a buffer
                buffer = fs.readFileSync(localItemFullPath);

            var remoteItemExists = false;

            for (var i = 0; i < remoteFolderItems.length; i++) {
                var remoteItem = remoteFolderItems[i];

                if (remoteItem.title === localItemName) { // local item already in the remote item list
                    if (stat.isDirectory())
                        // synchronizes sub-folders
                        syncLocalFolderWithRemoteFolderId(localItemFullPath, remoteItem.id);

                    else
                        // following function will compare md5Checksum 
                        // and will update the file contents if hash is different
                        updateSingleFileIfNeeded(buffer, remoteItem);

                    remoteItemExists = true;

                    // item is in both local and remote folders, remove its index from the array
                    remoteItemsToRemoveByIndex = 
                    	remoteItemsToRemoveByIndex.filter(function (val) { return val != i }); 
                    break;
                }
            }

            if (!remoteItemExists)
                // local item not found in remoteFolderItems, create the item (file or folder)
                createRemoteItemAndKeepGoingDownIfNeeded
                	(localItemFullPath, buffer, remoteFolderId, stat.isDirectory());
        });

        // removes remoteItems that are not in the local folder (ie not accessed previously)
        remoteItemsToRemoveByIndex.forEach(function (index) {
            var remoteItem = remoteFolderItems[index];
            deleteSingleItem(remoteItem);
        });
    }

该函数读取位于 localFolderPath 中的本地文件和文件夹,并将这些文件/文件夹与 remoteFolderItems(Google 云端硬盘上的项)进行比较。

如果它是一个文件,它会将其完整读取到缓冲区中,以便稍后可以计算 md5hash 和/或将其内容推送到 Google 云端硬盘。如果它是一个文件夹,它会调用 syncLocalFolderWithRemoteFolderId 函数,以便在子文件夹上进行相同的处理。

该函数会跟踪已访问的远程项 - 如果某个特定项未被访问,则表示它在本地不存在,应该将其删除(这是我的用例)。

您可以查看完整的源代码,了解我如何实现 updateSingleFileIfNeededcreateRemoteItemAndKeepGoingDownIfNeededdeleteSingleItem

使用 JavaScript Google API

使用最新 JavaScript API 的一个好方法是下载其源代码并查看函数注释以了解用法和预期参数。

当在很短的时间内进行多次 API 调用时,Google API 将开始抛出与用户配额相关的错误。为了解决这个问题,我创建了 invokeLater() 函数,该函数会再次尝试调用该方法(使用 setTimeout 和一个随机数)。

以下是使用的 Google API 方法列表:

  • oauth2Client.generateAuthUrl - 生成用户应遵循以请求访问令牌的 Google URL
  • oauth2Client.getToken - 使用用户输入的代码获取 Google 身份验证令牌
  • drive.files.list - 获取文件和文件夹列表。与 'parentId in parents' 一起使用以获取特定目录下的项,并与条件 (trashed=false) 一起使用以跳过已删除的项。当文件夹包含许多项 (100+) 时,该函数可能需要多次调用,直到 nextPageToken 未定义。
  • drive.files.insert - 在特定的 parentId 文件夹下创建文件或文件夹。将 mime 类型设置为 'application/vnd.google-apps.folder' 以创建文件夹项。要创建文件,请使用文件的内容(缓冲区)填充项的媒体属性。
  • drive.files.update - 如果远程文件的 md5Checksum 属性与本地文件的 md5 哈希值不同,则更新远程文件。与 drive.files.insert() 相同,使用文件的内容(缓冲区)填充文件的媒体属性。
  • drive.files.delete - 删除具有指定文件 ID 的文件或文件夹。

运行应用程序
设置依赖项

在您的计算机上安装 NodeJs,请参阅以下步骤:
https://node.org.cn/en/download/package-manager/#windows

启用 Google 云端硬盘 API - 请参阅以下步骤:https://developers.google.com/drive/web/quickstart/nodejs#step_1_enable_the_api_name

创建一个包含 gdrive 应用程序的文件夹,并将文件 gdrive.app.js 复制到其中,例如 c:\dev\nodeJs\gdrive

安装 Google 云端硬盘客户端库,在提示符下输入:

npm install googleapis --save

现在您的文件夹应该如下所示:

授权应用程序

运行应用

node gdrive.app.js

由于应用程序尚未获得授权,JavaScript Google SDK 将生成一个带有请求的 URL。将 URL 复制到您的浏览器并允许该请求。

代码将显示在浏览器中(见下文)或重定向查询字符串中。将代码粘贴到 nodejs 应用程序中。

您的 nodeJs 应用程序文件夹现在应该如下所示:(注意新的 auth.json 文件)

如果 auth.json 中缺少 refresh_token,很可能是因为您已经授权了用户。解决此问题的一个简单方法是在 Google 安全网络 UI 中删除/撤销访问权限并重试。

运行代码

根据您的需求调整主方法调用 syncFolder。例如:

syncFolder('C:/tmp/gdrive', 'tmp/test123');

这将把您本地文件夹 c:/tmp/gdrive 中找到的文件和文件夹上传到您的 Google 云端硬盘帐户的 /tmp/test123/ 路径。远程文件夹 tmptest123 将根据需要创建。如果您将目标远程文件夹留空,文件和文件夹将被上传到您的 Google 云端硬盘帐户的根目录(不建议)。

如果需要备份的文件存储在多个硬盘中,您可以多次调用 syncFolder 函数:

syncFolder('C:/photos', 'photos/c');

syncFolder('D:/photos', 'photos/d');

重要提示:路径中不要使用反斜杠,所以输入 'c:/photos' 而不是 'C:\photos'。

未来的增强

可以改进代码的几点:

  • 将文件夹作为输入参数传递
  • 安全存储 API 密钥/令牌
  • 批量上传文件而不是一次一个
  • 首次运行后,使用文件/文件夹监视器仅处理新建/更新的文件/文件夹
  • 并非在所有情况下都重试调用(非配额相关问题,例如远程文件夹已满)

结论

只用不到 300 行代码,我们编写了一个 nodeJs 应用程序,让您可以将重要的文件夹备份到您的免费 Google 云端硬盘帐户中。

© . All rights reserved.