Go
GoogleAppEngine
googleapi
GoogleDrive
GSuite

Google ドライブで変更が発生したら通知を受け取る

こんにちは!先日 Raspberry Pi Zero W を購入して Google Home との連携を画策している @wezardnet です。ラズパイを実際に触るのは初めてで、まったくの :beginner: なので Qiita の記事とか読みながらセットアップしました。Zero W はケーブルレスでインターネットに接続できるので配線まわりはスッキリします♪

さて本題に入ります。今回は過去に書いた(手前味噌でスミマセン :pray:)「Google カレンダーの予定に変更が発生したら通知を受け取る(Reboot)」のスピンオフ的な感じで Google ドライブ版を書いてみようと思います。

Google カレンダーと同様に Google ドライブにも変更が発生したら、自分のアプリケーションに通知される Push Notifications 仕組みが用意されています。こちらは Google ドライブ全体を監視して変更を通知させるか、あるいは特定のファイルに変更が発生した時のみ通知させるかの 2 パターンがあります。今回も GAE/Go で挙動を確認してみようと思います。

1. 事前準備

事前準備は Google カレンダーの場合とまったく同じですので割愛します。「Google カレンダーの予定に変更が発生したら通知を受け取る(Reboot)」の事前準備を参照ください てへぺろ(・ω<)

2. 実装手順(GAE/Go)

まずは必要なパッケージをインポートします。尚、コードは見づらくなるので一部省略してます。

Import
import (
    "bytes"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "net/url"
    "time"

    "github.com/bitly/go-simplejson"
    "github.com/mjibson/goon"
    "github.com/satori/go.uuid"

    "google.golang.org/appengine"
    "google.golang.org/appengine/datastore"
    "google.golang.org/appengine/log"
    "google.golang.org/appengine/urlfetch"
)

2.1. Notification Channels を作る

2.1.1 Google ドライブ全体の変更を監視する場合

Channel を作る前にスタート ページトークンを取得する必要があります。このスタート ページトークンは後述の Channel 作成のときに必要になります。公式ドキュメント(Changes: getStartPageToken | Drive REST API)も併せて見ておくと良いでしょう。

GetStartPageToken
ctx := appengine.NewContext(r)

req, _ := http.NewRequest(
    "GET", 
    "https://www.googleapis.com/drive/v3/changes/startPageToken", 
    nil, 
)
req.Header.Set("Authorization", "Bearer {有効な OAuth2 アクセストークン}")
req.Header.Set("Content-Type", "application/json; charset=utf-8")

client := urlfetch.Client(ctx)
resp, err := client.Client.Do(req)
if err != nil {
    log.Errorf(ctx, "err = %s", err.Error())
    return
}

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
    log.Errorf(ctx, "drive api request error.: %s", err.Error())
    return
} else if resp.StatusCode != http.StatusOK {
    log.Errorf(ctx, "drive api request error.: %d, %s", resp.StatusCode, string(body))
    return
}

data, err := simplejson.NewJson(body)
if err != nil {
    log.Errorf(ctx, "err = %s", err.Error())
    return
}
return data.Get("startPageToken").MustString()

次に Google ドライブの変更を監視する Channel を作ります。

CreateChannels
type m map[string]interface{}

ctx := appengine.NewContext(r)

params := m {
    "id": uuid.NewV4().String(), 
    "type": "web_hook", 
    "address": "https://myappid.appspot.com/driveWatch", 
}
payload, _ := json.MarshalIndent(params, "", "    ")

req, _ := http.NewRequest(
    "POST", 
    "https://www.googleapis.com/drive/v3/changes/watch?pageToken=" + url.QueryEscape({スタート ページトークン}), 
    bytes.NewReader(payload), 
)
req.Header.Set("Authorization", "Bearer {有効な OAuth2 アクセストークン}")
req.Header.Set("Content-Type", "application/json; charset=utf-8")

client := urlfetch.Client(ctx)
resp, err := client.Client.Do(req)
if err != nil {
    log.Errorf(ctx, "err = %s", err.Error())
    return
}

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
    log.Errorf(ctx, "drive api request error.: %s", err.Error())
    return
} else if resp.StatusCode != http.StatusOK {
    log.Errorf(ctx, "drive api request error.: %d, %s", resp.StatusCode, string(body))
    return
}

リクエストボディ(JSON)は以下のとおりです。

  • id ・・・ 一意になる Channel ID を設定します
  • type ・・・ 「web_hook」固定です
  • address ・・・ プッシュ通知を受け取る URL を設定します
  • token ・・・ 自由な値が設定可能で、何も指定しなくても OK です
  • expiration ・・・ Channel の有効期限(UNIX タイムスタンプ[ミリ秒])です

成功すると Channel が作成され、次のような JSON のレスポンスが返ってきます。

{
    "expiration": "1516752503000",
    "id": "7045be27-3dfa-4200-9c16-fa1d32344aee",
    "kind": "api#channel",
    "resourceId": "ddiixCVmASnAg9qWY**********",
    "resourceUri": "https://www.googleapis.com/drive/v3/changes?includeCorpusRemovals=false\u0026includeRemoved=true\u0026includeTeamDriveItems=false\u0026pageSize=100\u0026pageToken=8762710\u0026restrictToMyDrive=false\u0026spaces=drive\u0026supportsTeamDrives=false\u0026alt=json",
    "token": ""
}

レスポンス(JSON)は以下のとおりです。

  • expiration ・・・ Channel の有効期限(UNIX タイムスタンプ[ミリ秒])
  • id ・・・ リクエストパラメータで指定した id と同じ(Channel ID)
  • kind ・・・ 種別(api#channel 固定と思われる)
  • resourceId ・・・ 監視リソースの ID
  • resourceUri ・・・ この Channel が監視しているリソース(ドライブ)の URI

resourceId は Channel を削除する時に必要になるので、データストアなどに Channel ID と一緒に格納しておくと良いでしょう。

2.1.2 Google ドライブ内の特定のファイルの変更を監視する場合

特定のファイルを監視する場合はスタート ページトークンを取得する必要はないので Channel の作成から始めます。

CreateChannels
type m map[string]interface{}

ctx := appengine.NewContext(r)

params := m {
    "id": uuid.NewV4().String(), 
    "type": "web_hook", 
    "address": "https://myappid.appspot.com/driveWatch", 
}
payload, _ := json.MarshalIndent(params, "", "    ")

req, _ := http.NewRequest(
    "POST", 
    fmt.Sprintf("https://www.googleapis.com/drive/v3/files/%s/watch", url.QueryEscape({監視対象のファイル識別子})), 
    bytes.NewReader(payload), 
)
req.Header.Set("Authorization", "Bearer {有効な OAuth2 アクセストークン}")
req.Header.Set("Content-Type", "application/json; charset=utf-8")

以下省略

リクエストボディ、レスポンス共に「Google ドライブ全体の変更を監視する場合」と同じなので割愛します。

2.2. プッシュ通知を受け取る側を作る(Webhook)

Channel を作成すると、リクエストボディの address で指定した URL に、次のようなヘッダーが付いたリクエストが飛んできます。

state=sync
Host = myappid.appspot.com
User-Agent = APIs-Google; (+https://developers.google.com/webmasters/APIs-Google.html)
Accept = */*
X-Goog-Channel-ID = 7045be27-3dfa-4200-9c16-fa1d32344aee
X-Goog-Channel-Expiration = Wed, 24 Jan 2018 00:08:23 GMT
X-Goog-Resource-State = sync
X-Goog-Message-Number = 1
X-Goog-Resource-ID = ddiixCVmASnAg9qWY**********
X-Goog-Resource-URI = https://www.googleapis.com/drive/v3/changes?includeCorpusRemovals=false&includeRemoved=true&includeTeamDriveItems=false&pageSize=100&pageToken=8762710&restrictToMyDrive=false&spaces=drive&supportsTeamDrives=false&alt=json
Accept-Charset = UTF-8
X-AppEngine-Country = ZZ

ドライブ全体の変更を監視している場合、ドライブに何らかの変更が入ると、次のようなヘッダーが付いたリクエストが飛んできます。上記との違いは、「X-Goog-Resource-State」の値になります。この値が「sync」の時は Channel を作成した時の通知で、「change」の時はドライブに変更があった時の通知を表します。

state=change
Host = myappid.appspot.com
User-Agent = APIs-Google; (+https://developers.google.com/webmasters/APIs-Google.html)
Accept = */*
X-Goog-Channel-ID = 7045be27-3dfa-4200-9c16-fa1d32344aee
X-Goog-Channel-Expiration = Wed, 24 Jan 2018 00:08:23 GMT
X-Goog-Resource-State = change
X-Goog-Message-Number = 84392
X-Goog-Resource-ID = ddiixCVmASnAg9qWY**********
X-Goog-Resource-URI = https://www.googleapis.com/drive/v3/changes?includeCorpusRemovals=false&includeRemoved=true&includeTeamDriveItems=false&pageSize=100&pageToken=8762710&restrictToMyDrive=false&spaces=drive&supportsTeamDrives=false&alt=json
Accept-Charset = UTF-8
X-AppEngine-Country = ZZ

ドライブ内の特定のファイルの変更を監視している場合、ファイルに何らかの変更が入ると、次のように「X-Goog-Resource-State」の値が「update」でリクエストが飛んできます。

state=update
Host = myappid.appspot.com
User-Agent = APIs-Google; (+https://developers.google.com/webmasters/APIs-Google.html)
Accept = */*
X-Goog-Channel-ID = b67b8fac-d853-46d6-980b-d0652e52d6fe
X-Goog-Channel-Expiration = Wed, 24 Jan 2018 01:11:51 GMT
X-Goog-Resource-State = update
X-Goog-Message-Number = 84392
X-Goog-Resource-ID = -4xGRuJEfZxBmPHV7**********
X-Goog-Resource-URI = https://www.googleapis.com/drive/v3/files/{監視対象のファイル識別子}?acknowledgeAbuse=false&supportsTeamDrives=false&alt=json
Accept-Charset = UTF-8
X-AppEngine-Country = ZZ

Google カレンダーと同様にプッシュ通知で送られてくる情報はコレだけです。ドライブ全体の変更を監視している場合は、どのファイルに変更が入ったのかわからないため別途、「Changes: list | Drive REST API」を叩きます。この時のリクエストパラメータの pageToken には、初回は前述のスタート ページトークンをセットします。次回通知を受け取った時は API 実行後に返ってくるレスポンスに newStartPageToken が含まれるので、それをセットするようにします。以降はこの繰り返しで、前回 API 実行時よりも後にドライブに変更があったモノだけを取得するという格好になります。

driveWatchHandler(state="change")
ctx := appengine.NewContext(r)

req, _ := http.NewRequest(
    "GET", 
    "https://www.googleapis.com/drive/v3/changes?pageToken={ページトークン}", 
    nil, 
)
req.Header.Set("Authorization", "Bearer {有効な OAuth2 アクセストークン}")

client := urlfetch.Client(ctx)
resp, err := client.Client.Do(req)
if err != nil {
    log.Errorf(ctx, "err = %s", err.Error())
    return
}

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
    log.Errorf(ctx, "drive api request error.: %s", err.Error())
    return
} else if resp.StatusCode != http.StatusOK {
    log.Errorf(ctx, "drive api request error.: %d, %s", resp.StatusCode, string(body))
    return
}

以下のように Drive API のレスポンス(JSON)が返ってきます。この中の newStartPageToken の値は次に API を叩くときに必要になるのでデータストアなどに格納しておきましょう。

{
    "changes": [
        {
            "file": {
                "id": "1UxYzq7oQbPfJmvHh8VYzWF**********",
                "kind": "drive#file",
                "mimeType": "aplication/json",
                "name": "State.json"
            },
            "fileId": "1UxYzq7oQbPfJmvHh8VYzWF**********",
            "kind": "drive#change",
            "removed": false,
            "time": "2018-01-23T23:09:47.541Z",
            "type": "file"
        }
    ],
    "kind": "drive#changeList",
    "newStartPageToken": "8762711"
}

データストアには Channel ID をキーにして次のように格納しておくと良いでしょう。

model
type DrivePush struct {
    Id             string           `datastore:"-" goon:"id"`                                   // Channel ID
    Expiration     time.Time        `datastore:"expiration" datastore_type:"Integer"`           // Channel の有効日時
    PageToken      string           `datastore:"pageToken" datastore_type:"String"`             // 取得用のトークン
    ResourceId     string           `datastore:"resourceId" datastore_type:"String"`            // 監視対象のリソース識別子
    FileId         string           `datastore:"fileId" datastore_type:"String"`                // 監視対象のファイル識別子
    UserId         string           `datastore:"userId" datastore_type:"String"`                // ドライブにアクセスするユーザー識別子(メールアドレス)
    CreatedAt      time.Time        `datastore:"createdAt" datastore_type:"DateTime"`           // 作成日時
    UpdatedAt      time.Time        `datastore:"updatedAt" datastore_type:"DateTime"`           // 更新日時
}

2.3. Notification Channels の削除

必要無くなった Channel や、期限切れの Channel を削除する場合のコード例を示します。前述しましたが、削除するには「Channel ID」と「resourceId」が必要になるので Channel 作成時にはこれらの値を必ずデータストアなどに記録しておきます。

DeleteChannels
type m map[string]interface{}

ctx := appengine.NewContext(r)

params := m {
    "id": {Channel ID}, 
    "resourceId": {resourceId}, 
}
payload, _ := json.MarshalIndent(params, "", "    ")

req, _ := http.NewRequest(
    "POST", 
    "https://www.googleapis.com/drive/v3/channels/stop", 
    bytes.NewReader(payload), 
)
req.Header.Set("Authorization", "Bearer {有効な OAuth2 アクセストークン}")
req.Header.Set("Content-Type", "application/json; charset=utf-8")

client := urlfetch.Client(ctx)
resp, err := client.Client.Do(req)
if err != nil {
    log.Errorf(ctx, "err = %s", err.Error())
    return
}

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
    log.Errorf(ctx, "drive api request error.: %s", err.Error())
    return
} else if resp.StatusCode != http.StatusNoContent {
    log.Errorf(ctx, "drive api request error.: %d, %s", resp.StatusCode, string(body))
    return
}

3. ライフサイクル

Notification Channels は有効期限があります。有効期限が切れる前に Channel を作り直す処理などを検討しておく必要があります。

4. 所感

Google カレンダーの場合とほぼ同じですね。ちなみに Google ドライブ全体の変更を監視する場合、自分に共有されているファイルも対象になります。不要な通知を避けたい場合は迷わず特定のファイルのみ監視するようにした方が良いですね。ドライブ全体の変更を通知を受け取るユースケースは思いつきませんでした...