背景
こんにちは、@harhogefooです。
Google Apps Scriptを使ってスプレッドシートの情報を取得して何かやりたいことはあると思います。
ただ、GASを使うと並行処理が書けません。
なので、複数のスプレッドシートを参照して何かしたい場合、実行時処理時間がかかってしまいます。
そこで、今回はGolangのgroutineを使って、スプレッドシート情報を並行に取得する方法をまとめました。
サービスアカウントを利用してスプレッドシート情報にアクセスする
前提知識を得ておくために公式ドキュメントのQuickstartを読んでおくことがおすすめです。
https://developers.google.com/sheets/api/quickstart/go
上記リンクに書いてある内容をまとめると
- Google APIsのSpreadsheet APIを有効化し、OAuthクライアントIDを発行
 - このIDを利用してプログラムからスプレッドシートにアクセス
 - プログラム実行時にブラウザが立ち上がりGoogleアカウントログインを求められ、ログインをすると認証情報がファイルに保存される。これ以降は認証が不要
 
ただ、今回は認証フローを省きたかったので
サービスアカウントを利用したスプレッドシート情報を取得する方法を採用しました。
この方法は以下にまとまっているので、ここを参考にサービスアカウントの作成からスプレッドシートへアクセスするところまでをサクッと実装できます。
https://qiita.com/bati11/items/a4cd922149dac07981bc
参考までにスプレッドシートへアクセスするためのクライアントを取得するコードを載せておきます。
import "golang.org/x/oauth2/google"
func getHTTPClient(credentialsJSONData []byte) (*http.Client, error) {
    conf, err := google.JWTConfigFromJSON(credentialsJSONData, "https://www.googleapis.com/auth/spreadsheets")
    if err != nil {
        return nil, errors.WithStack(err)
    }
    return conf.Client(oauth2.NoContext), nil
}
oauth2/googleのドキュメントのリンク↓
https://godoc.org/golang.org/x/oauth2/google
Spreadsheet APIを使ってスプレッドシートのセル情報を取得する
Spreadsheet APIを利用してスプレッドシート情報にアクセスする方法は3つあります。
この方法については以下にまとまっていますが、選択理由を書くためこの記事でも整理しておきます。
https://qiita.com/howdy39/items/5473160c93030c386c2d
①spreadsheets.get + includeGridDataオプションを付与して取得する
スプレッドシートの全てのセル情報を含めて取得します。includeGridDataオプションを付与しないとセル情報無しで返ってきます。
Golangの具体的なコードに落とすと
import "google.golang.org/api/sheets/v4"
func main() {
    client, _ := getHTTPClient() // ここは先述のサービスアカウント情報を使ってクライアントを取得する
    service, _ := sheets.New(client)
    
    resp, _ := service.Spreadsheets.Get(spreadsheetID).IncludeGridData(true).Do()
    // ごにょごにょ
}
SpreadsheetsService.Getのドキュメントは以下↓
https://godoc.org/google.golang.org/api/sheets/v4#SpreadsheetsService.Get
②spreadsheets.values.get で取得する
スプレッドシート内のシートと範囲を指定して取得します。
import "google.golang.org/api/sheets/v4"
func main() {
    client, _ := getHTTPClient(credentialsJSONData)
    service, _ := sheets.New(client)
    
    rangeStr := "シート名" // シート名!A1:A99 のような記述をすれば範囲の指定も可能
    
    resp, _ := service.Spreadsheets.Values.Get(spreadsheetID, rangeStr).IncludeGridData(true).Do()
    // ごにょごにょ
}
SpreadsheetsValuesService.Getのドキュメントは以下↓
https://godoc.org/google.golang.org/api/sheets/v4#SpreadsheetsValuesService.Get
③spreadsheets.values.batchGetで取得する
gRPCの構文を使ってスプレッドシートから複数のシート情報を取得することができます。
試していないのでサンプルコードはありません![]()
今回採用したセル情報取得方式
①spreadsheets.get + includeGridDataオプションを付与して取得する を採用しました。
②を採用しなかった理由
- シートごとにリクエストするため、大量に実行するとすぐにAPI制限に引っかかってしまう
- スプレッドシートの読み取りは100秒あたり500リクエストまで
 
 
③を採用しなかった理由
- スプレッドシート内のセル情報全てを取得したかった
 - ①の方が簡潔にかけそう
 
取得したセル情報をパースする
①spreadsheets.get + includeGridDataオプションを付与して取得する 方式で情報を取得すると以下の形式で返ってきます。
https://godoc.org/google.golang.org/api/sheets/v4#Spreadsheet
APIドキュメントをたどるとセル情報は、SpreadsheetのSheetsのData配列のRowData配列のValues配列 にそれぞれ格納されており、Valuesは CellData型で定義されている(深い)。
CellDataのドキュメント↓
https://godoc.org/google.golang.org/api/sheets/v4#CellData
CellDataは、FormattedValue、EffectiveValue、UserEnteredValueを持っている。
それぞれについて整理しておくと、
FormattedValue:  整形された値(スプレッドシートに表示されている値)。戻り値は、string。
EffectiveValue: 評価された値。戻り値は、ExtendedValue。
UserEnteredValue:  入力された値。戻り値は、ExtendedValue。
ExtendedValueのドキュメント↓
https://godoc.org/google.golang.org/api/sheets/v4#ExtendedValue
ExtendedValueは、型ごとに情報を持っています。
文字列ならExtendedValue.StringValueに
数値ならExtendedValue.NumberValueに (ただし戻り値はfloat)
真偽値なら ExtendedValue.BoolValueに格納されています。
セル情報の取得と表示のサンプルコード
import "google.golang.org/api/sheets/v4"
func main() {
    client, _ := getHTTPClient() // ここは先述のサービスアカウント情報を使ってクライアントを取得する
    service, _ := sheets.New(client)
    
    resp, _ := service.Spreadsheets.Get(spreadsheetID).IncludeGridData(true).Do()
    
    // セル情報の取得
    for _, s := range resp.Sheets {
        for _, row := range s.Data[0].RowData {
            for _, value := range row.Values {
                fmt.Println(value.FormattedValue)
                fmt.Println(value.EffectiveValue.StringValue)
                fmt.Println(value.UserEnteredValue.StringValue)
            }
        }
    }
}
どのValueを採用するか?
これは実際に実装をして得た知見なのですが、日付、時間データは、ExtendedValueだと妙な値が入っています(よくわからなかったので知見がありましたら教えていただきたいです)。
なので、FormattedValue(string)として扱い必要に応じて整形して読み取ります。
それ以外の数値、文字列データに関しては、EffectedValueから読み取ります。UserEnteredValueは使いませんでした。
EffectiveValueに関してはいい感じに値を取得するコードを書きました。
func getValueFromEffectiveValue(ev *sheets.ExtendedValue) interface{} {
    if ev == nil {
        return ""
    }
    if ev.StringValue != "" {
        return ev.StringValue
    }
    if isInteger(ev.NumberValue) {
        return math.Floor(ev.NumberValue)
    }
    return ev.NumberValue
}
スプレッドシート情報を並行に取得する
さて、ここまででスプレッドシートのセル情報を取得できるようになりましたが
複数のスプレッドシートに直列アクセスをすると時間がかかるので、並行にスプレッドシートにアクセスして情報を取得します。
Golangの並行アクセス方法はgroutineやWaitGroup、errorgroupがあります。
今回はエラーハンドリングはしたいので、errorgroupを採用します。
こんな感じのコードを書きました。
import  (
    "sync"
    "golang.org/x/sync/errgroup"
)
func getSpreadsheetDataByID(
    mutex *sync.Mutex,
    spreadsheetID string,
    output *[]string,
) func() error {
    return func() error {
       // スプレッドシートからセル情報を取得する
       // ごにょごにょ
       
       // 並行に同一のリソースにアクセスする場合はmutexでLockをかけておくと安全
       mutex.Lock()
       // ここで取得したセル情報をoutputに格納する
       mutex.Unlock()
    }
}
func main() {
    output := make([]string, 0)
    mutex := &sync.Mutex{}
    var eg errgroup.Group
    spreadSheetIDs := []string{"xxx", "yyy", "zzz"}
    for _, spreadSheetID := range spreadSheetIDs {
        // NOTE: https://golang.org/doc/faq#closures_and_goroutines
        spreadSheetID := spreadSheetID
        eg.Go(getSpreadsheetDataByID(mutex, spreadSheet, &output))
    }
    
    if err := eg.Wait(); err != nil {
        return nil, err
    }
    
    // outputで何かする
}
errorgroupのちょっとした罠
前章でerrorgroupを使いましたが、 // NOTE  コメントの下の行を見ると
spreadsheetID := spreadsheetID で再代入を行っています。
これを行わないと、常にリストの最後の値を参照して並行処理が走ります(期待した値が取れない)。
細かい理由についてはこちらにまとまっていますので参照してください。
https://qiita.com/harhogefoo/items/7ccb4e353a4a01cfa773
終わりに
いかがでしたでしょうか。
誰かのお役に立てたら幸いです!
Happy Coding!