GASを使って、Google Cloud Storageに定期的にCSVをアップロードする

#背景

Cloud StorageにGoogle Driveから自動で定期的にファイルをアップロードしたかったのですが、
もろもろ試した結果、Google App Scriptを利用してアップロードのバッチ処理を組むことができたのでメモ。

公式ドキュメントが散らばっていたのと、Google APIの仕様がやや複雑だったので色々なところでハマりましたが、なにかの参考になれば幸いです。

# 概要
やることは主に2つ。

- サービスアカウントキーを使って
- Google Cloud Storage APIでファイルをGETする

簡単にこの2つについて説明します。

まず、今回はCloud Storageへ操作を行いたいので、何かしらの方法でGCP環境にアクセスする必要があります。
一般的には(たとえばPython環境で)クライアントライブラリを使ったり、Cloud Shellを使ったりすると思いますが、
今回はGASを使ってアクセスするので、Googleが用意しているAPIを使っていきます。

さて、実際にいじる前に、Google Cloud APIsの認証方法を見てみましょう。



https://cloud.google.com/docs/authentication?hl=ja#strategies

>Google Cloud APIs では、ユーザー アカウントとサービス アカウントの認証の両方に、OAuth 2.0 プロトコルが使用されます。OAuth 2.0 を使用した認証プロセスにより、プリンシパルとアプリケーションの両方を識別します。

このように書いてあるので、基本的な座組は「OAuthを利用してGoogleAPIにアクセスする」ようなものらしいですね。

また、要件＆推奨されるアクセス方法は以下と書いてあります。

| 要件 | 推奨 |	コメント|
|------|------|--------|
|一般公開データに匿名でアクセスする|	API キー	|API キーはアプリケーションを識別するだけであり、ユーザー認証は必要とされません。一般公開データへのアクセスには、これで十分です。|
エンドユーザーに代わって限定公開データにアクセスする	|OAuth 2.0 クライアント	|OAuth 2.0 クライアントによりアプリケーションが識別され、Google でアプリケーションを認証できるようになります。これにより、アプリケーションがエンドユーザーに代わって Google Cloud APIs にアクセスできます。|
Google Cloud 環境内でサービス アカウントに代わって限定公開データにアクセスする	|環境提供のサービス アカウント	|Compute Engine、App Engine、GKE、Cloud Run、Cloud Functions などの Google Cloud 環境内でアプリケーションを実行する場合、アプリケーションはその環境で提供されるサービス アカウントを使用する必要があります。Google Cloud クライアント ライブラリは、サービス アカウントの認証情報を自動的に検索して使用します。
|Google Cloud 環境外でサービス アカウントに代わって非公開データにアクセスする	|サービスアカウントキー|	サービス アカウントを作成し、その秘密鍵をJSONファイルとしてダウンロードする必要があります。そのファイルを Google Cloud クライアント ライブラリに渡して、実行時にサービス アカウントの認証情報を生成できるようにする必要があります。Google Cloud クライアント ライブラリは、GOOGLE_APPLICATION_CREDENTIALS 環境変数を使用して、サービス アカウントの認証情報を自動的に検索して使用します。|


今回のケースだと、

- 「OAuth2.0クライアント」
- 「サービスアカウントキー」

のどちらかを使うかな？ということが分かります。




これら2つの違いについては以下の通りです。

**OAuth2.0クライアント**

- アプリケーションが、エンドユーザーに代わって「OAuth2.0クライアントID」「クライアントシークレット」を使ってアクセスする。
- 認証の際に、ユーザーに対し権限のスコープを提示し同意を求める。(ユーザーアカウントを使ってログインや同意を行い、ユーザー単位で同意する)

**サービスアカウントキー**

- サービスアカウントを使ってアクセスする。
- 認証の際に秘密鍵を使う。ユーザーの同意は必要ない。


サービスアカウントについてもう少し詳しく見てみましょう。

https://developers.google.com/identity/protocols/oauth2?hl=ja#serviceaccount
>「アプリケーションはサービスアカウントに代わってGoogleAPIを呼び出し、ユーザーの同意は必要ありません。 （サービスアカウント以外のシナリオでは、アプリケーションがエンドユーザーに代わってGoogle APIを呼び出し、ユーザーの同意が必要になる場合があります。）」

このように書いてありますね。
要するにサービスアカウントはユーザーの同意が必要ないので、イチイチ実行の際に同意画面で承諾する必要がないということです(※1)。
アプリケーション開発の際などはGUIでユーザーにいろいろ同意を求める必要があると思いますが、今回はそういった用途ではなく単にGASでバッチ処理を行いたいだけなのでサービスアカウントでよさそうです。

これらをかんがみて、今回は、

- サービスアカウントキーを使って
- Google Cloud Storage APIでファイルをGETする

ことを試みました。

※1...サービスアカウントでの認証の仕組みの図

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/660844/55a8fb12-3da1-4024-bfa6-bc7e5e2c3df6.png)














# 実装

https://developers.google.com/identity/protocols/oauth2?hl=ja#serviceaccount

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/660844/f4b91635-2ee2-531a-665c-58a24bfc02b2.png)

この図にのっとって、以下の手順で行っていきます。

1. サービスアカウントの作成～秘密鍵の発行
2. GASでの認証リクエスト～トークンの取得
3. GASでドライブ上のファイルを取得
4. 3.で取得したファイルを、Cloud Storage JSON APIを使ってアップロード




## 1. サービスアカウントの作成～秘密鍵の発行

### サービスアカウントの作成
お使いのGoogle Cloud Console画面にアクセス。
APIs & Services → Credentialsにいきます。以下のような画面。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/660844/3e5ff21c-cc78-0886-0776-056d77a368d1.png)

先述の「APIキー」「OAuth 2.0 Client IDs」「Service Accounts」といった認証に使えるアイテムが並んでいるかと思います。

上の「CREATE CREDENTIALS」から認証に使うアイテムが作成できるので、今回はService Accountを作成。Nameなども適当につけておきましょう。

### 秘密鍵の取得
サービスアカウントについて、JSON形式の秘密鍵を作成します。
⚠このとき、当たり前ですが、秘密鍵はぜったいに外部に漏らしてはいけません。




以下のような秘密鍵がダウンロード出来たらOK。

```json
{
  "type": "service_account",
  "project_id": "xxxxxx",
  "private_key_id": "xxxxxx",
  "private_key": "xxxxxxx",
  "client_email": "xxxxx@xxxxx.iam.gserviceaccount.com",
  "client_id": "xxxxxxx",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "xxxxxx"
}
```



## 2. GASでの認証リクエスト～トークンの取得

つぎに、GASで認証のリクエストとトークンの取得を実装していきます。

※以下の記事を参考にさせていただきました。

https://qiita.com/soundTricker/items/63da2f5cf98088542821


### 認証用ライブラリの実装

今回はGASでのService Account認証用公開ライブラリを使いましょう。

ソースコードは以下：
Apps-Script-GSApp-Library

https://github.com/Spencer-Easton/Apps-Script-GSApp-Library

ありがたく使わせていただきます。
GAS用に作られている公開ライブラリは、エディタの左側の「Library」からIDを打ち込むことで読み込むことができます：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/660844/8732d22c-1da5-aaf5-5daf-6aabb45cc776.png)



・・・ここでライブラリを読み込む予定でしたが、どうやらこちらのライブラリが現在非公開になっているらしく、
エラーが出てしまいました(ID : MJ5317VIFJyKpi9HCkXOfS0MLm9v2IJHf)。
ので、ソースコードをもとに、ライブラリを新たに作成しましょう。
作成したライブラリをGSAppとして読み込んでおきます。

これでいざトークン取得、と思いきや、以下の部分でバグがあったので、先んじて修正しておきましょう。
なかなか一筋縄ではいかない…

**※修正前**

```js
 if(!(Scopes_.constructor === Array)){
    throw "The Scopes must be in a valid array"
}
```

どうやらArray判定してくれているみたい？だが、うまく判定できずエラーが出てしまっていた。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/constructor

**※修正後**

```js
  if(!(Array.isArray(Scopes_) == true)){
    throw "The Scopes must be in a valid array"
  }
```

Array判定すればいいならこれでいいはず…

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray




これで無事にGASでのService Account認証用ライブラリが用意できました👌

あとはこれを使ってリクエストを作成し、トークンを取得しましょう。

### リクエストとトークン取得

リクエストに必要な情報は以下。

- サービスアカウントのメールアドレス
- サービスアカウントの秘密鍵
- スコープ




サービスアカウントの情報はダウンロードしたJSONにすべて入っています。(秘密鍵は"private key"のところをつかう)

スコープの一覧はこちらに置いてあるので見てみましょう。

https://developers.google.com/identity/protocols/oauth2/scopes#storage




|スコープ| |
|-------|-|
|https://www.googleapis.com/auth/cloud-platform|Google CloudPlatformサービス全体でデータを表示および管理する|
|https://www.googleapis.com/auth/cloud-platform.read-only|Google CloudPlatformサービス全体でデータを表示する|
|https://www.googleapis.com/auth/devstorage.full_control|Google CloudStorageでデータと権限を管理する|
|https://www.googleapis.com/auth/devstorage.read_only|Google CloudStorageでデータを表示する|
|https://www.googleapis.com/auth/devstorage.read_write|Google CloudStorageでデータを管理する|

今回は上から3番目のものを使ってみることにします。

※なお、アクセストークンは一度取得すると1時間で失効してしまうので、今回はたたくたびにトークンを取得できるようにしておきます。




該当の箇所は以下：

```js
function getStorageService() {
    // Scopeを "https://www.googleapis.com/auth/devstorage.full_control" に設定
    var serverToken = new GSApp.init(PRIVATE_KEY,["https://www.googleapis.com/auth/devstorage.full_control"],SERVICE_ACCOUNT_EMAIL);
    
	//トークンを取得するユーザを設定して、トークンを取得
    var tokens = serverToken.addUser(SERVICE_ACCOUNT_EMAIL).requestToken().getTokens();
	
	//アクセストークンを確認
    Logger.log(tokens[SERVICE_ACCOUNT_EMAIL].token);
    
	return tokens;
  }
```

なお、安全のため、PRIVATE_KEY, SERVICE_ACCOUNT_EMAILは事前にプロパティストアに格納し、以下で別途取りに行くようにしています。

```js
const scriptProp = PropertiesService.getScriptProperties();
const PRIVATE_KEY = scriptProp.getProperty("private_key");
const SERVICE_ACCOUNT_EMAIL = scriptProp.getProperty("service_account_email");
```

## 3. GASでドライブ上のファイルを取得

3については詳細は割愛。Driveappを使いましょう。getBolb()メソッドを使うことでファイルが取得できます。

だいたいこんな感じ：

```js
//ディレクトリやファイルのパス
var dirName = "xxxxx";
var filename = "xxxx.csv";
var dir = DriveApp.getFoldersByName(dirName);  
var file = DriveApp.getFilesByName(filename).next();
var content = file.getBlob();
```

## 4. 3.で取得したファイルを、Cloud Storage JSON APIを使ってアップロード

CSVのアップロードについては以下のように書きます。

APIリファレンス：

https://cloud.google.com/storage/docs/json_api/v1?hl=ja

https://cloud.google.com/storage/docs/downloading-objects?hl=ja#downloading-an-object


```js
function storeContentIntoGCS(content, bucketName, filePath) {
    var tokens = getStorageService();
    var token = tokens[SERVICE_ACCOUNT_EMAIL].token;
 
      var url='https://storage.googleapis.com/'+ bucketName + filePath;
 
      UrlFetchApp.fetch(url,{
        headers: {
          Authorization: "Bearer " + token,
        },
        method: "PUT",
        contentType: "application/javascript;charset=utf-8",
        host: bucketName + ".storage.googleapis.com",
        payload: content
      });
  
    return true;  
  }
```

※以下の記事を参考にさせていただきました


https://dev.classmethod.jp/articles/google-apps-script-spreadsheet-csv2gcs/#toc-1


# 実行してみる

実行すると無事にCloud Storageにアップロードされていることがわかります👏

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/660844/c3818576-90f1-d19f-537c-c76f102af41a.png)

あとはこれをGASのスケジューリング機能でスケジュールすれば完成です。

GASは6分でタイムアウトしてしまうのであまり大規模な処理には向きませんが、スケジュール機能があるので、サクッとこういったスクリプトを作成したい場合は小回りが利いて便利ですね。