Google App ScriptをTypeScriptとClass構文で書く - 実装

前回は「Google App ScriptをTypeScriptとClass構文で書く - 環境導入」ということでClaspでローカル開発した場合の恩恵と、どう環境を設定したらいいかという話を書きました。

今回はES6から使えるようになったClass構文をつかって、TypeScriptでうまいことGASを書いていきます。本題です。
ちなみにこのClass構文はJSがそもそも持つprototypeの実装を簡単に書ける糖衣構文という位置付けです。

また、今回はTypeScriptに不慣れな人でもわかりやすくするためあえて型に関しての記述は少なくしました。慣れているほうは型をどんどん利用するともっと書きやすくなるのでオススメです。

Claspが正式にTypeScript対応してくれて事前ビルドなくtsをそのままpushできるようになって非常に楽になりました。その事情から逆に発生してしまっている特有の落し穴についても最後のほうで触れています。

TL;DR

  • GitHubのイベント(PRなど)をWebhookで受けてChatworkに通知するサンプル
  • ES6のClassと継承使うと責務の分離と共通動作の取り回しがしやすくなる
  • GAS(Clasp)+TypeScript特有の落とし穴があるから気をつけろ

想定

サンプルの題材としてGoogleSpreadSheetをDB的に扱って、その情報を参照して自動化する想定をします。例として、GitHubのプルリクエストの状態に応じてChatworkに通知をしてみます。具体的にはプルリクエストの状態の変化時にWebhookが飛ぶのでそれをGAS側で受けて、Chatworkの通知にしています。

SpreadSheetとしてはこんなかんじでシート2つあります。

  1. members: メンバーの名前、ChatworkのID、GitHubのIDを持っている名簿的なシート
  2. repositories: リポジトリ名、リポジトリのURL、対応する通知先のChatworkルームID

ちなみに各SpreadSheetの1行目は各カラムのタイトル行とします。

最終的にやりたいことは、登録したリポジトリでプルリクエストに変化があったらChatworkの指定したルームに関係者にToをつけて通知する、という流れです。これを細かくすると、

  1. GitHubでPRのイベントをトリガーとしてGASにWebhookを飛ばす(GitHub側で設定)
  2. GASで受けてWebhookの内容をパースする
  3. パースした内容にしたがって通知メッセージや通知先をGoogleSperedSheetから取得する
  4. Chatworkに通知する

という流れになります。

開発

基底クラスのconstructor

まず基底クラスとしてGasSheetというクラスを作ってみます。
こいつの役割は、各シートを扱うための情報の読み込みと検索、書き替えの機能を提供します。

GASのAPIでSpreadSheetのデータを範囲でとってきた場合、2次元配列になります。
したがって、例えば上の画像の名簿データは

[
  ['cwID', 'name', 'githubID'],
  ['1111111111111', '雨宮 蓮', 'joker'],
  ['2222222222222', '坂本 竜司', 'skull'],
  ['3333333333333', '高巻 杏', 'panther'],
  // 中略...
]

という配列の中に各行が列ごとに値を区切られた配列として取得できます。

今回はこれだと扱いづらかったので、まずはGasSheetクラスをnewして生成したときに、
シートのデータをカラム名をキーに持つオブジェクトに変換して格納することにしました。

とりあえずnew GasSheet(sheet)な感じでシートを受けとって処理できるようにしてみます。
01_GasSheet.ts

export default class GasSheet {
  sheet: any // SheetClass from GAS
  columns: { columnNum: number; name: string }[]
  data: {}[]
  constructor(sheet) {
    this.sheet = sheet
    const rawColumns: any[] = sheet
      .getRange(1, 1, 1, sheet.getLastColumn())
      .getValues()[0]
    const columns: { columnNum: number; name: string }[] = []
    rawColumns.forEach((dataOfColumn, idx) => {
      columns.push({ columnNum: idx + 1, name: dataOfColumn })
    })
    const rawData: any[][] = sheet
      .getRange(2, 1, sheet.getLastRow(), sheet.getLastColumn())
      .getValues()
    const data: {}[] = []
    rawData.forEach((dataOfRow, idx) => {
      const obj = { rowNum: idx + 1 + 1 } // 行番号は1スタート + HEADERの行
      columns.forEach((column, i) => {
        obj[column.name] = dataOfRow[i]
      })
      data.push(obj)
    })
    this.columns = columns
    this.data = data
  }
}

使うときはたとえば

const SHEETS = SpreadsheetApp.openById(ここにスプレッドシートのID)
const MEMBERS_SHEET = SHEETS.getSheetByName(`members`)
const gasSheet = new GasSheet(MEMBERS_SHEET)
console.log(gasSheet.data[0])
// => { rowNum: 2, cwID:'111111111', name:'雨宮 蓮', githubID:'joker'}

みたいな感じですね。

基底クラスのメソッド

これだとまだ機能的にはオブジェクトの形になるようにラップしただけなので、メソッドから各データを取れるように実装してみましょう。

ちなみに配列、オブジェクト、コレクションを扱いやすくするためGASのライブラリとしても用意されているUnderscoreを使います。Underscoreにならって、今回は

  • where: 引数で指定したキーの値が合致する複数のオブジェクトを配列に入れて返すメソッド
  • findWhere: 引数で指定したキーの値が合致した最初のオブジェクトを返すメソッド

の2つを実装してみます。なお、本来なら見つからなかった場合など中でエラーハンドリングすべきですが、今回は省略します。
00_GasSheet.ts

const _ = Underscore.load() // Underscoreライブラリのロード
export default class GasSheet {
  sheet: any // SheetClass from GAS
  columns: { columnNum: number; name: string }[]
  data: {}[]
  constructor(sheet) {
    // 中略(上で紹介した通り)
  }
  where(keyValue): {}[] {
    return _.where(this.data, keyValue)
  }
  findWhere(keyValue): {} {
    return _.findWhere(this.data, keyValue)
  }
}

そして使うときは

const SHEETS = SpreadsheetApp.openById(ここにスプレッドシートのID)
const MEMBERS_SHEET = SHEETS.getSheetByName(`members`)
const gasSheet = new GasSheet(MEMBERS_SHEET)
console.log(gasSheet.findWhere({name: '坂本 竜司'}))
// => { rowNum: 3, cwID:'222222222', name:'坂本 竜司', githubID:'skull'}

みたいな感じです。

継承したクラスを作る

GasSheetクラスができたので、これを利用した別のクラスを作っていきます。すでにオブジェクト指向的な言語に触れてる方にはいまさら説明の必要がないかもしれませんが、GASはいろいろな人が触っているようなので簡単に説明します。

例えばAというクラスを継承したA-1、A-2というクラスを作ったとします。AクラスがもつメソッドはA-1,A-2ともなにもせずとも使えます。ですがA-1だけのメソッドはA-2では使えません、逆もそうです。すごいざっくり言えば共通したいところは共通化すること、共通化しないところは個別でしか使えないという責務の分離の両方を実現できます。

じゃあ実際にやっていきます。

方針として、GasSheetクラスを継承させてMembersSheetクラスとRepositoriesSheetクラスを作っていきます。MembersSheetクラスは単純に任意の値から該当するデータを取得すればいいのでシンプルに継承したもの、Repositoriesクラスにはnotifyというメソッドを作って通知できるように実装します。

01_MembersSheet.ts

import GasSheet from './00_GasSheet'
const SHEETS = SpreadsheetApp.openById(ここにスプレッドシートのID)
const MEMBERS_SHEET = SHEETS.getSheetByName(`名簿`)

export default class MembersSheet extends GasSheet {
  constructor() {
    super(MEMBERS_SHEET)
  }
}

MembersSheetはこれだけでOKです。注目すべきは、extends GasSheetと継承しているところ、そしてconstructor()は引数を使ってないところです。
superは継承元(GasSheetクラス)の同名メソッドを呼びますので、super()に引数をわたすことで先程の例でやっています。

const MEMBERS_SHEET = SHEETS.getSheetByName(`members`)
const gasSheet = new GasSheet(MEMBERS_SHEET)

と同じことをしています。

こうすると使うときは先程よりもシンプルになって

const membersSheet = new MembersSheet()
console.log(gasSheet.findWhere({name: '坂本 竜司'}))
// => { rowNum: 3, cwID:'222222222', name:'坂本 竜司', githubID:'skull'}

とするだけでOKになります。

次にRepositoriseSheetクラスを作ります。前半はMemberslSheetと同様です。
02_RepositoriesSheet.ts

import GasSheet from './00_GasSheet'
const SHEETS = SpreadsheetApp.openById(ここにスプレッドシートのID)
const REPOSITORIES_SHEET = SHEETS.getSheetByName(`repositories`)
const gasSheet = new GasSheet(REPOSITORIES_SHEET)

export default class RepositoriesSheet extends GasSheet {
  constructor() {
    super(REPOSITORIES_SHEET)
  }
  notify(notification) { // notification = {repo: url, to: id, message: msg }
    // 最初にrepositoriesのシートからURLによりどのプロジェクトか特定する
    const repo = this.findBy({repositoryURL: notification.repo})
    // メッセージを引数で来たオブジェクトを使って整形する
    const message =`[To:${notification.to}][info][title]${repo.name}[/title]${notification.message}[/info]`
    // ライブラリ経由でAPIを叩いて通知する
    const client = ChatWorkClient.factory({ token: ここにCWトークン })
    return client.sendMessage({
     room_id: repo.room_id,
     body: message,
   })
  }
}

ChatWorkClientは非公式ですが、Chatwork通知用のライブラリがあるのでそれを使っています。
cw-shibuya/chatwork-client-gas: Chatwork Client for Google Apps Script

notifyメソッドでやっていることは、notificationという仮引数の名前でオブジェクトとして引数で、リポジトリのURL、 通知するメンバーのID、通知内容を取ります。

それにしたがって、メソッド内で適切な形にメッセージ内容や通知を飛ばす先のルームを設定しています。Chatworkでは[To: ID][title]などの独自タグで通知先や強調表示できます。普段Chatworkを使っていないほうは適宜そんな感じか、となんとなく見てください。

ここでのポイントthis.findBy()です。findByは継承元のGasSheetクラスに実装してあるので、使うことができます。つまりリポジトリシート情報からURLが合致するリポジトリの情報を取得しています。

CWトークンはコード内にベタで書くよりはPropertiesServiceなどを環境変数的に利用するのが良いと思いますが、ここでその説明は割愛します。

Webhookをトリガーにしてクラスとそのメソッドを使う

あともう一息ですね。ここまでで必要なクラスができたので、実際にWebhookを受けてメッセージを飛ばす実装をしていきます。

今回はサンプルとしてあるプルリクエストがマージされたときに通知するとしてみましょう。

GASの仕様でWebhookとしてリクエストが飛んできたものはdoPost()関数で受けることができて、その時のbodyに入ってくる内容は引数に渡せます(今回はeとして扱う)それをいったんパースして、そのあとで使いやすくしています。
doPost.ts

import MembersSheet from './01_MembersSheet'
import RepositoriesSheet from './02_RepositoriesSheet'
export function doPost(e) {
  const contents = JSON.parse(e.postData.contents)
  // membersシートを扱う準備
  const membersSheet = new MembersSheet()
  // membersシートからgithubIDの該当する人を探す
  const membrer = memberSheet.findBy({githubID: contents.sender})
  // repositoriesシートを扱う準備
  const repositoriesSheet = new RepositoriesSheet()
  // 通知内容をオブジェクトとしてまとめる
  const notification = {
    repo: contents.repositoryUrl,
    to: member.cw_id,
    message: `${contents.title}がマージされました!`
  }
  // repositoriesシートを使って通知を実行する
  repositoriesSheet.notify(notification)
}

実装的にはMemberSheetクラスをインスタンスでwebhookに載ってきた情報からGitHubのIDから通知先をメンバーを特定します。

RepositoriesSheetクラスのインスタンスを作って、先程実装したnotifyメソッドに必要な情報を引数として渡しています。

ここでマージの場合はこう、レビューの場合はこう、みたいなハンドリングを省略しましたが、もしやりたい場合は書く必要があります。文章の出しわけも同様ですね。
今実際動いているものはシートのクラスとは別に例えばGitHubEventというクラスを作ってうまいことやるようにしています。

デプロイと注意点

注意点

あとは上記のスクリプト郡をデプロイすればいいだけですが、ここでGAS+TypeScript特有の落とし穴があります。

GASでES6のimport/exportは使えない

まさかと思いますよね、マジなんです。
じゃあ上のコードでimport/exportしてるのはなんでだ、って話なんですがこれはエディタの補完を効かせたりLintのためだったりです。実際$clasp pushするとコメントアウトされます。

さらにその特殊な事情として

import {
  functionA,
  functionB
} from `fileA`

みたいに書くとそのコメントアウトもまさしく働かくなってしまうのでやっちゃだめです。かなり罠です、お気をつけください。importを書くときは1行に書くのはGASでやるときは守っておいてください。

で、じゃあどうやって別ファイルに定義したものを使えるかというとGASは別ファイルに定義したものも他ファイルで使える全てがグローバルな仕様です。なので動かすだけならimportexportはいりません。
つまりimport/exportは使えないが結果的に同じことは実現できている、という状況です。

またimportがすべてコメントアウトされるためimportによる定義もできてません。なので、通常は自由な名前で定義できるところをClass名と厳密同じ名前でimportするようにします。

GasSheet.ts(export側)

export class GasSheet {
  // 略...
}

import側

// ◯ 良い
import GasSheet from './GasSheet'

// × ダメな例(export時の名前と違う
import MySheet from 'GasSheet`

さらにこの仕様につながって、どうやらファイルはファイル名順に読み込まれ、読み込み前のものは使えない、という仕様があるっぽいです(要出展)。
なのでこのご時世としてはやりたくないですが、01_とか読み込まれて欲しい順でファイル名をつけます。

もう1つあります。直接使われる関数(e.g. doPost())はexport defaultしちゃうと上手く動きません。exportがある分には大丈夫ですがexport defaultとして宣言してはだめです。そういうこともあって先程のdoPost

export function doPost(e) {
  // 中略
}

として定義しています。

デプロイと本番化

ClaspとGASの連携の話になりますが、通常Clasp経由でGASのコードを更新するには

$ clasp push

とします。これでGASのスクリプトエディタで開くコードが更新されます。もし開いたままだったらリロードしてください。

ちなみにClaspがTypeScript対応したことによる事前にtscなどは必要ありません。pushすると.tsファイルは.gsにトランスパイルされてアップロードされます。

ここでの注意点はなんか上手く反映されないときがあるので、リロードしたあと一度スクリプトエディタ上で保存すると上手くいくことがあるようです。このへんの挙動は謎です。

単純にGASにコードを追いて手動実行したりする場合はこれだけでいいんですが、Webhookを受けとるような場合ではWebアプリケーションとして公開する必要があります。また、公開するには版(バージョン)としてデプロイされていることが必要です。このため

$ clasp deploy

を実行します。この時引数をつけないで実行すると新しい版としてデプロイされます。

あとはGASのスクリプトエディタのほうで、公開 > Webアプリケーションとして導入とします。
この時に表示されるURLがWebhookを受けるURLなのでコピーしておいてGitHub側に設定します。
プロジェクトバージョンは先程デプロイしたときに発行されたバージョンを指定します。
他の権限の設定はやることによって最適なものが変わるので、設定します。

最後に

今実際に僕が動かしてるものはもうすこし多様性を持たせた結果、サンプルで扱うにはデカすぎるようになってしまったので公開して紹介が難しく残念です(もし改変して公開できる余裕ができたらぜひやりたい)

あと次回、もし続けばテストについて書けたらいいなあと思っています。

GASってVBA的に捉えてる層もいれば、JS系で書けるWebアプリとか自動化できるおもちゃみたいに考えてる層もいます。
この記事はそんな隔絶した層のちょうど溝を埋めるような記事として読まれたらいいなあ、と思っています。

日々の回復力を最大化するささやかな抵抗Google App ScriptをTypeScriptとClass構文で書く - 環境導入