Google カレンダーの予定に変更が発生したら通知を受け取る(Reboot)

  • 12
    いいね
  • 0
    コメント

こんにちは。最近手のひらサイズのミニドローンを手に入れてときどき練習してるのですが、まったく上達しない @wezardnet です :sweat:

さてさて、今回は社内向けのアプリを作るということで Google カレンダーをコンテンツ配信の予定登録に利用したいとの要件が出てきました。配信予定は追加、更新、削除が適時発生するので Google カレンダーに変更が発生したら、自分のアプリケーションに通知してくれる仕組みが必要になります。

というわけで 2 年前に @shin1ogawa さんの Google Calendar上の予定の変更を監視する という解説記事を元に調査/検証した Google Calendar API Push Notifications を自社で利用している G Suite のカレンダーを対象に GAE/Go 上に通知を受け取るサンプルを作って再検証してみたので、共有したいと思います。以前に比べると準備段階の手順はかなり簡易(省略できるよう)になっていると思います。

1. 事前準備

はじめに Google Developer Console の API Manager から Credentials の Domain verification を選択します。ココで App Engine アプリのドメイン所有確認を行います。

Domain verification.png

Add domain でドメインを追加するのですが、「 https://myappid.appspot.com 」とすることで appspot.com なドメインも登録することができます。
ウェブマスター セントラルに画面が遷移するので、提示された確認方法のいずれかでサイト所有者の確認を行ってください。

WebMaster Central.png

HTML タグ、あるいは HTML ファイルをアップロード あたりの確認方法が楽ちんです。

以上で準備は完了ですっ。えっ、これだけ??
はい、そうです。カスタムドメインの設定や G Suite への登録などは必要ありません :ok_hand:

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 を作る

はじめに 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/calendarWatch", 
}
payload, _ := json.MarshalIndent(params, "", "    ")

req, _ := http.NewRequest(
    "POST", 
    fmt.Sprintf("https://www.googleapis.com/calendar/v3/calendars/%s/events/watch", url.QueryEscape({監視対象のカレンダー ID})), 
    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, "calendar api request error.: %s", err.Error())
    return
} else if resp.StatusCode != http.StatusOK {
    log.Errorf(ctx, "calendar 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": "1496211140000",
    "id": "29992f29-5a21-44c5-a0b3-5aca788e76f8",
    "kind": "api#channel",
    "resourceId": "HF4QdpE-bORhVDGy8nxbO******",
    "resourceUri": "https://www.googleapis.com/calendar/v3/calendars/{監視対象のカレンダー ID}/events?maxResults=250\u0026alt=json"
}

レスポンスパラメータ(JSON)は以下のとおりです。

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

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

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

Channel を作成すると、リクエストパラメータの「address」で指定した URL に対して、次のようなヘッダーが付いたリクエストが飛んできます。

sync
Host = myappid.appspot.com
User-Agent = APIs-Google; (+https://developers.google.com/webmasters/APIs-Google.html)
Accept = */*
X-Goog-Channel-ID = 29992f29-5a21-44c5-a0b3-5aca788e76f8
X-Goog-Channel-Expiration = Wed, 31 May 2017 08:16:38 GMT
X-Goog-Resource-State = sync
X-Goog-Message-Number = 1
X-Goog-Resource-ID = HF4QdpE-bORhVDGy8nxbO******
X-Goog-Resource-URI = https://www.googleapis.com/calendar/v3/calendars/{監視対象のカレンダー ID}/events?maxResults=250&alt=json
Accept-Charset = UTF-8
X-AppEngine-Country = ZZ

監視対象の Google カレンダーの予定に何らかの変更が入ると、次のようなヘッダーが付いたリクエストが飛んできます。上記との違いは、「X-Goog-Resource-State」の値になります。この値が「sync」の時は Channel を作成した時の通知で、「exists」の時はカレンダーの予定に変更があった時の通知を表します。

exists
Host = myappid.appspot.com
User-Agent = APIs-Google; (+https://developers.google.com/webmasters/APIs-Google.html)
Accept = */*
X-Goog-Channel-ID = 29992f29-5a21-44c5-a0b3-5aca788e76f8
X-Goog-Channel-Expiration = Wed, 31 May 2017 08:16:38 GMT
X-Goog-Resource-State = exists
X-Goog-Message-Number = 149905778
X-Goog-Resource-ID = HF4QdpE-bORhVDGy8nxbO******
X-Goog-Resource-URI = https://www.googleapis.com/calendar/v3/calendars/{監視対象のカレンダー ID}/events?maxResults=250&alt=json
Accept-Charset = UTF-8
X-AppEngine-Country = ZZ

プッシュ通知で送られてくる情報はコレだけです。どの予定に変更が入ったのか、変更内容については教えてくれません。 → えぇ~、これじゃ使えないじゃん。と思いますよね!
つまり、「Google Calendar API Push Notifications」は、カレンダーの予定に変更があったことだけを知らせてくれるだけで、変更された予定は自分で別途 Google Calendar API を叩いて取得しなければならないのです。

プッシュ通知はカレンダーの予定が変更されたというトリガーの役割だけで利用し、通知が来たら「Google Calendar API Events: list」を使って変更された予定を取得するようにします。このAPIを実行すると、そのレスポンスに「nextSyncToken」という値が含まれています。この値を次に API を実行する時、「syncToken」というパラメータに付加してリクエストを出すと、前回実行した時よりも後に変更があった予定のみ取得することができます。この機能を利用して、「X-Goog-Resource-State=sync」の通知を受けた時に、次のように「Google Calendar API Events: list」をコールして取得します。

calendarWatchHandler(state="sync")
ctx := appengine.NewContext(r)

var state string = r.Header.Get("X-Goog-Resource-State")

var api string = fmt.Sprintf("https://www.googleapis.com/calendar/v3/calendars/%s/events", url.QueryEscape({監視対象のカレンダー ID}))
api += "?singleEvents=true"

req, _ := http.NewRequest(
    "GET", 
    api, 
    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, "calendar api request error.: %s", err.Error())
    return
} else if resp.StatusCode != http.StatusOK {
    log.Errorf(ctx, "calendar api request error.: %d, %s", resp.StatusCode, string(body))
    return
}

通常の Calendar API と同じレスポンス(JSON)が返ってきます。この中の「nextSyncToken」の値をデータストアなどに格納しておき、次にプッシュ通知を受け取った時に使います。

{
    "accessRole": "owner",
    "defaultReminders": [],
    "description": "",
    "etag": "\"p32ganpc3na6d80g\"",
    "items": [],
    "kind": "calendar#events",
    "nextSyncToken": "CKCr5YO6jNQCEKCr5YO6jN******",
    "summary": "監視テスト用カレンダー",
    "timeZone": "Asia/Tokyo",
    "updated": "2017-05-26T01:55:07.844Z"
}

カレンダーの予定が変更されると、「X-Goog-Resource-State=exists」でプッシュ通知が来るので、今度は「syncToken」というパラメータに前回実行した時の「nextSyncToken」の値をセットして、「Google Calendar API Events: list」をコールします。

calendarWatchHandler(state="exists")
ctx := appengine.NewContext(r)

var state string = r.Header.Get("X-Goog-Resource-State")

// API で一度に取得できる件数は最大 2,500 件までなのでページングも考慮しておく
var api string = fmt.Sprintf("https://www.googleapis.com/calendar/v3/calendars/%s/events", url.QueryEscape({監視対象のカレンダー ID}))
api += "?singleEvents=true"
api += "&syncToken={nextSyncToken の値}"

req, _ := http.NewRequest(
    "GET", 
    api, 
    nil, 
)
req.Header.Set("Authorization", "Bearer {有効な OAuth2 アクセストークン}")

(以下省略)

次のように前回実行した時より後に変更があった予定の情報のみが返ってきますので、次に実行する時のためにまた「nextSyncToken」の値をデータストアに格納しておきます。以降はこの繰り返しになります。

{
    "accessRole": "owner",
    "defaultReminders": [],
    "description": "",
    "etag": "\"p33ofrilbtq6d80g\"",
    "items": [
        {
            "created": "2017-05-26T05:49:09.000Z",
            "creator": {
                "displayName": "テスター",
                "email": "**********@njc.co.jp"
            },
            "description": "テストっす",
            "end": {
                "dateTime": "2017-05-27T11:00:00+09:00"
            },
            "etag": "\"2991555499516000\"",
            "hangoutLink": "https://plus.google.com/hangouts/_/njc.co.jp/njc-co-jp-bnj54?hceid=bmpjLmNvLmpwX2JuajU0aGNqOGFlMTQ0amlyaTQ2OWIxbmRzQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20.e7piucblvtml1phvjou8******",
            "htmlLink": "https://www.google.com/calendar/event?eid=ZTdwaXVjYmx2dG1sMXBodmpvdTh1czJjNWMgbmpjLmNvLmpwX2JuajU0aGNqOGFlMTQ0amlyaTQ2OWIxb******",
            "iCalUID": "e7piucblvtml1phvjou8******@google.com",
            "id": "e7piucblvtml1phvjou8******",
            "kind": "calendar#event",
            "organizer": {
                "displayName": "監視テスト用カレンダー",
                "email": "njc.co.jp_bnj54hcj8ae144jiri46******@group.calendar.google.com",
                "self": true
            },
            "reminders": {
                "useDefault": true
            },
            "sequence": 0,
            "start": {
                "dateTime": "2017-05-27T10:00:00+09:00"
            },
            "status": "confirmed",
            "summary": "ねこぽん",
            "updated": "2017-05-26T05:49:09.758Z"
        }
    ],
    "kind": "calendar#events",
    "nextSyncToken": "CPD9yqvujNQCEPD9yqvujN******",
    "summary": "監視テスト用カレンダー",
    "timeZone": "Asia/Tokyo",
    "updated": "2017-05-26T05:49:09.942Z"
}

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

model
type CalendarPush struct {
    Id             string       `datastore:"-" goon:"id"`                               // Channel ID
    Expiration     time.Time    `datastore:"expiration" datastore_type:"Integer"`       // Channel の有効日時
    NextSyncToken  string       `datastore:"nextSyncToken" datastore_type:"String"`     // 次回同期用のトークン
    ResourceId     string       `datastore:"resourceId" datastore_type:"String"`        // 監視対象のリソース識別子
    CalendarId     string       `datastore:"calendarId" 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/calendar/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, "calendar api request error.: %s", err.Error())
    return
} else if resp.StatusCode != http.StatusNoContent {
    log.Errorf(ctx, "calendar api request error.: %d, %s", resp.StatusCode, string(body))
    return
}

2.4. Notification Channels バッチ処理

期限切れの Channel は作り直しが必要になります。一例として翌日に期限が切れそうな Channel を cron でデイリー処理させる仕組みを紹介します。

cron.png

尚 Google Calendar まわりの処理は独自のヘルパーパッケージにまとめてます。

Import
import (
    "net/http"
    "net/url"
    "time"

    "bitbucket.org/{my project}/lib/helper"
    "bitbucket.org/{my project}/model"

    "github.com/mjibson/goon"

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

全体的なフローは、、、

  1. データストアから期限が切れそうな Channel の抽出
  2. Channel の削除(Stop)
  3. データストアから対象の Channel の情報を削除
  4. Channel の作成
  5. 作成した Channel の情報をデータストアに登録 ← Webhook 側で

な感じにしてありますが 4,5 と 2,3 の順は逆の方が安全かも知れません。私が検証した限りでは、同じ resourceId に対して複数の Channel を張っても同じ「nextSyncToken」が返ってくるので、取りこぼしがないようにするには、削除 → 再作成よりは、再作成 → 削除の方が良いかと思います。

calendarChannelHandler
func calendarChannelHandler(c web.C, w http.ResponseWriter, r *http.Request) {
    ctx := appengine.NewContext(r)
    g := goon.NewGoon(r)

    now := time.Now()
    jst, _ := time.LoadLocation("Asia/Tokyo")
    limit := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, jst)
    limit = limit.AddDate(0, 0, 1)  // 1 日後

    // 翌日零時までに有効期限が切れそうな Channel を抽出して作り直す
    lists := []model.CalendarPush{}
    keys, err := g.GetAll(datastore.NewQuery("CalendarPush").Filter("expiration <=", limit).Order("expiration"), &lists)
    if err != nil {
        log.Errorf(ctx, "datastore query error.: %s", err.Error())
        return
    }
    log.Infof(ctx, "keys.size = %d", len(keys))

    for i, k := range keys {
        calendar, err := helper.NewCalendar(ctx, r, lists[i].UserId, false)
        if err != nil {
            log.Errorf(ctx, "fatal new calendar.: %s", err.Error())
        }

        // FIXME: リトライを想定しておくなら Task Queue に積んで処理させるべきか...
        err = calendar.DeleteChannel(k.StringID(), lists[i].ResourceId)
        if err != nil {
            log.Errorf(ctx, "fatal delete calendar channel.: %s", err.Error())
        } else {
            err = g.Delete(k)
            if err != nil {
                log.Errorf(ctx, "fatal delete datastore.: %s", err.Error())
                continue
            }

            _, _, err = calendar.CreateChannel(lists[i].CalendarId, lists[i].CalendarId)
            if err != nil {
                log.Errorf(ctx, "fatal create calendar channel.: %s", err.Error())
            }
        }
    }
}

3. 所感

Google のテクノロジーは日々進化しているので 2 年前には問題がなかったとしても現在はどうなっているかわかりません。今回は特にハマるところも少なく、無事期待どおりの挙動をすることが確認できたので、この仕組みを採用できそうです。
Google の API やテクノロジーはよく仕様が変わったり、廃止(deprecated)されたりするので、プロダクトには採用できないとか、ネガティブな意見を聞きます(ウチの社内だけかも知れませんがw)。しかしながら、古い技術を切り捨てて、新しい技術に置き換えていくことは今の時代では当たり前のことではないでしょうか。逆にそういった先進的な技術について行けない(来れない)会社は淘汰されていく時代だと私は危惧します。