Vercel + GatsbyJSのキャッシュの設定

**VercelとはVercel社(旧ZEIT)が開発しているサーバレスなホスティングサービスです。**VercelはCDNであるためJAMStackなアプリケーションをデプロイするために最適で、Vue、Nuxt.js、React、Next.js、GatsbyJSなどフロントエンドのアプリケーションのデプロイ先として相性がとても良いです。

GatsbyJSとは、React.js製の静的サイトジェネレータです。SSRをすることでビルド時に最適化された静的ファイルを生成するため、サイトの表示速度が爆速になります。

GatsbyJSについては「GatsbyJSで実現する、高速&実用的なサイト構築」という本が詳しいです。GatsbyJSの構成やブログの作り方などが解説されています。

解説パンダくん
解説パンダくん

GatsbyJSはブログで使われることが多いけど、人材採用のページなどで使われるケースも増えてきているね。

この記事では、VercelにGatsbyJSをデプロイする時のオススメのキャッシュの設定方法をご紹介します。

関連記事: NetlifyでGatsbyJSのキャッシュを設定してレスポンス速度を爆速にしよう

GatsbyJSのキャッシュ戦略はファイル名の変更の有無で決める

GatsbyJSのキャッシュ戦略は、ざっくり分けるとファイル名が変わらないものはキャッシュせず、ビルドのたびにファイル名が変わるものはキャッシュするというものです。

ビルドごとに名前が変わるファイルは、cache-controlヘッダーにmax-age=31536000を付与して1年間キャッシュします。これはビルドのたびにファイル名に一意なハッシュが与えられ、サイトにアクセスするたびに読み込むファイルが変わるため、ブラウザは古いファイルを読み込むことがないからです。

解説パンダくん
解説パンダくん

例えばapp.jsは出力時app-c2875e4f24d448537cff.jsのようになるんだよ。ハッシュ値がつくからファイル名はビルドのたびに変わるんだね。

反対にpage-data.jsonなどはビルドするとファイルの内容が変わるものの、ファイル名は変わらないため、キャッシュしないのがGatsbyJS公式の推奨設定です。

Vercelの設定ファイルvercel.jsonにキャッシュの設定を書く

**Vercelの設定は、vercel.jsonに書き込みます。**vercel.jsonはルートディレクトリに作成しましょう。

Vercelでレスポンスに含めるHTTPヘッダーを設定するときは、以下のようにheadersの項目に記述します。

vercel.json
{
  "headers": [
    {
      "source": "(.*).html",
      "headers":[
        // ...
      ]
    }
  ]
}

sourceは該当するファイル名を指定します。Vercelはパスの取得に正規表現を使っていません。このため*.htmlという正規表現のシンタックスを書くことはできません。

Vercelはpath-to-regexpを利用しているので、全てのHTMLを指定する場合は(.*).htmlとsourceに記述します。

次に、HTTPヘッダーはheadersのkeyとvalueに記述します。

vercel.json
{
  "headers": [
    {
      "source": "(.*).html",
      "headers":[
        {
          "key" : "Cache-Control",
          "value" : "public, max-age=0, must-revalidate"
        }
      ]
    }
  ]
}

これで「HTMLファイルはキャッシュせず、必ずサーバーに問い合わせる」という設定が書けました。同様に、JSやCSS、sw.jsや画像ファイルにもキャッシュヘッダーを付与しましょう。

この記事の最後に、GatsbyJSが生成する全てのコンテンツに対してGatsbyJSが推奨するキャッシュヘッダーを付与する設定を書いたvercel.jsonを掲載しています(vercel.jsonの全文)。

なお、どのコンテンツにどんなヘッダーを付与するかは「GatsbyJS公式推奨のキャッシュ設定を理解する」という記事で解説しています。

Vercelはcache-controlにstale-while-revalidateを設定できる

Vercelはcache-controlのstale-while-revalidateをサポートしています。

stale-while-revalidateとは、「コンテンツをキャッシュしているならキャッシュからロードする。一方、サーバーにもリクエストを送り、キャッシュした内容と相違があるなら、サーバーからのレスポンスを表示する」という仕組みです。

stale-while-revalidateの特徴は、最初にキャッシュからコンテンツをロードするためコンテンツを表示するためにサーバーからレスポンスを待つ必要がなくなる一方、キャッシュしたコンテンツが古くなっていてもサーバーに問い合わせをしているので、後から最新のコンテンツが表示されるというものです。

解説パンダくん
解説パンダくん

最初はキャッシュからコンテンツを読み込むので、レスポンスを待たずにページが表示されるから早いんだね。

キャッシュヘッダーにstale-while-revalidateの設定を書くだけでこの便利な仕組みを使えます(Vercel公式ドキュメント)。

Cache-Control: s-maxage=1, stale-while-revalidate

なお、ReactのAPIからデータを取得するためのHooks、useSWRも、stale-while-revalidateからインスピレーションを受けたものです(useSWRについては最近紹介・解説記事書いています)。

まとめ

Vercelはだんだん日本で知られ始めてきたものの、日本語の情報はまだ少ないです。

しかし海外では着実にVercel社の注目度は上がっています。その証拠に、最近$21M、つまり2,100万ドル(約22億円)を調達し、投資家から「将来のフロントエンドエンジニアのAWSになる」と言われるなどにわかに注目を集めています。

個人開発をする人にとって、サーバーサイドの言語ならHeroku、フロントエンドならVercelにデプロイするのが最近の流れですね。

将来Vercelがフロントエンド界隈を席巻することは想像に難くないですね。

最後に、Vercel社CEOであるGuillermo Rauch氏がVercelを簡潔に紹介したTweetを掲載して筆を置くことにします。

vercel.jsonの全文

GatsbyJSが公式で推奨している設定をVercelで適用するためのvercel.jsonは以下の通りです。

Vercelがサポートしているstale-while-revalidateを利用して、さらにコンテンツの表示速度を早めるようにしています。

vercel.json
{
  "headers": [
    {
      "source": "(.*).html",
      "headers":[
        {
          "key" : "Cache-Control",
          "value" : "s-maxage=1, stale-while-revalidate"
        }
      ]
    },
    {
      "source": "/page-data/app-data.json",
      "headers":[
        {
          "key" : "Cache-Control",
          "value" : "s-maxage=1, stale-while-revalidate"
        }
      ]
    },
    {
      "source": "/page-data/(.*)",
      "headers":[
        {
          "key" : "Cache-Control",
          "value" : "s-maxage=1, stale-while-revalidate"
        }
      ]
    },
    {
      "source": "/static/(.*)",
      "headers":[
        {
          "key" : "Cache-Control",
          "value" : "public, max-age=31536000, immutable"
        }
      ]
    },
    {
      "source": "/sw.js",
      "headers" : [
        {
          "key" : "Cache-Control",
          "value" : "public, max-age=0, must-revalidate"
        }
      ]
    },
    {
      "source": "(.*).js",
      "headers":[
        {
          "key" : "Cache-Control",
          "value" : "public, max-age=31536000, immutable"
        }
      ]
    },
    {
      "source": "(.*).css",
      "headers":[
        {
          "key" : "Cache-Control",
          "value" : "public, max-age=31536000, immutable"
        }
      ]
    },
    {
      "source": "/icons/(.*)",
      "headers":[
        {
          "key" : "Cache-Control",
          "value" : "public, max-age=31536000, immutable"
        }
      ]
    },
    {
      "source": "/media/(.*)",
      "headers":[
        {
          "key" : "Cache-Control",
          "value" : "public, max-age=31536000, immutable"
        }
      ]
    }
  ]
}
VercelGatsbyJS
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

Next.jsからSlackのチャンネルに通知を送る方法

**Next.jsとはVercelが作成しているReactのフレームワークです。**面倒な設定を書かなくてもすぐに使えるZero Configを標榜しており、実際にwebpackやTypeScriptと一緒にReactを書く際にも特別な準備は不要です。SSRにも対応しており、Reactで開発するならNext.jsかFacebook製のCreate React Appを使うのがスタンダードになっています。

関連記事: Google Apps ScriptからSlackとLINEを連携するbotを作る手順を紹介します

また、**Slackとは、主にIT企業で導入されている非同期コミュニケーションが可能なチャットツールです。**様々なツールと連携できるところに特徴があり、サーバーの監視ログやあらゆる通知をSlackのチャンネルに集約できます。

この記事ではNext.jsからSlackに通知を送る方法を紹介します。

SlackのWebhook URLを漏れないようにする

**Slackに通知を送るにはWebhook URLを取得します。**始めはWebhook URLをブラウザからPOSTすれば簡単に実現できると考えていました。

しかし、ブラウザでWebhook URLにPOSTする際にユーザーがWebhook URLを知ることができることに思い至り、フロントからSlackに通知する方法は避けました。

それでも実現方法を考えていると、Next.jsのAPIの機能を使ってサーバー側からコードを実行すればWebhook URLが漏れてしまう問題を解決できることに思い至りました。そこで、実際に本記事の方法で実装しました。

Webhook URLを.envに設定する

まずはSlackチャンネルのWebhook URLを環境変数に設定します。

.env
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXXXXXXXXXX

SLACK_WEBHOOK_URLの値はprocess.env.SLACK_WEBHOOK_URLで取得します。なお.envに記述した環境変数は、Next.jsの中でどこからでも呼び出し可能です。しかし、配信されるJSにで値が公開されることはありません。

また、チャンネル毎のWebhook URLの取得方法は「slackのincoming-webhookに登録する」をご覧ください。

node-slack-sdkでWebhookにPOSTする

**Slackに通知を送るには、SlackのWebhook URLにPOSTリクエストをするということを書きました。**この処理はSlack公式のnode-slack-sdkというnodeモジュールを利用すると簡単に書くことができます。

lib/slack.tsに以下のようなコードを記述します。

lib/slack.ts
import { IncomingWebhook } from '@slack/webhook'

const slack = {
  sendToSlack: async (text: string) => {
    const url = process.env.SLACK_WEBHOOK_URL
    const webhook = new IncomingWebhook(url)
    await webhook.send({ text })
  },
}

export default slack

api/comments.tsにPOSTリクエストを送る

lib/slack.tsで作成したモジュールはNode.jsで動作します。Next.jsでは、apiを通じてサーバーサイドのコードを実行できます。

Next.jsでは、pages/api配下に作成したファイルがAPIのエンドポイントになります。/api/commentsというエンドポイントを作りたいときは、pages/api/comments.tsというファイルを置くことでパスとファイルを自動的にマッピングされます。

pages/api/comments.tsを作成する

まず、pages/api/comments.tsを作成します。ここで上記で作成したslackモジュールを実行します。

pages/api/comments.ts
import { NextApiRequest, NextApiResponse } from 'next'
import slack from 'lib/slack'

module.exports = async (req: NextApiRequest, res: NextApiResponse) => {
  const { message } = req.body

  if (typeof message === 'undefined') {
    res.writeHead(400).end('Invalid body: message')
  }

  if (req.method === 'POST') {
    // Slackのチャンネルにテキストを通知する
    await slack.sendToSlack(message)
    res.writeHead(201).end('Created')
  } else {
    // POST以外のメソッドは受け付けない
    res.writeHead(405).end('Method Not Allowed')
  }
}

これで/api/commentsにPOSTリクエストをするとSlackにメッセージを投稿できるようになりました。

エンドポイント/api/commentsにPOSTする

また、POSTリクエストにはfetchを使います。

const message = 'メッセージを投稿します'

fetch('/api/comments', {
  method: 'POST',
  mode: 'same-origin',
  credentials: 'same-origin',
  headers: { 'Content-Type': 'application/json; charset=utf-8' },
  body : JSON.stringify(message),
})

変数messageはSlackに送りたい文字列を格納してください。

[実践]ユーザーが投稿したコメントをSlackに通知する

では、実際にNext.jsからSlackに通知を送ります。ユースケースとして、「ユーザーが投稿したコメントをSlackに通知する」ことを想定します。

pages/index.tsx
import React, { useState, SyntheticEvent } from 'react'
import { NextPage } from 'next'

type Props = {
  comment: string
  handleChange: (e: SyntheticEvent) => void
  handleSubmit: (e: SyntheticEvent) => void
}

const Component: React.FC<Props> = (props) => (
  <form onSubmit={props.handleSubmit}>
    <input
      type="text"
      placeholder="コメントを入力..."
      value={props.comment}
      onChange={props.handleChange}
    />
    <button type="submit">投稿する</button>
  </form>
)

const Container: NextPage<{}> = () => {
  const [comment, setComment] = useState('')
  const handleChange = (e) => setComment(e.target.value)
  const handleSubmit = async (e) => {
    e.preventDefault()

    if (comment.trim() === '') {
      return
    }

    fetch('/api/comments', {
      method: 'POST',
      mode: 'same-origin',
      credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json; charset=utf-8' },
      body : JSON.stringify({message: comment}),
    })
  }

  return <Component {...{comment, handleChange, handleSubmit}} />
}

Container.displayName = 'IndexPage'

export default Container

エンドポイント/api/commentsにPOSTでユーザーが投稿するコメントを送信するReactコンポーネントが作成できました。

inputとbuttonの表示

「投稿する」ボタンを押すと、入力したメッセージがSlackに通知されます。

まとめ

Next.jsからSlackに通知を送る方法を紹介しました。なお、以下に記述したコードはサーバーサイドだけで実行され、bundleするJSには含まれません。

  • pages/api配下に作成したファイル
  • getServerSidePropsgetStaticPropsといったReactコンポーネントにデータを注入するための関数

Next.jsはフロントエンドとサーバーサイドの境界をうまく区切っているフレームワークですね。さらにNext.jsを使いこなしていきましょう。

Next.js
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

Google Apps ScriptをTypeScriptでローカルで開発する

Google Apps Script(以下、GAS)とはGoogleが開発したサーバレスな関数の実行環境です。GASはGoogleの各種サービスと連携してプログラムを実行できるため、業務やルーティンワークの自動化に最適です。

このブログでもGASを使ったハックを紹介してきました。

この記事では、claspというGoogle製のCLIツールを導入し、ローカル環境でTypeScriptを使ってGASを開発する方法をご紹介します。

まずclaspを導入し、TypeScriptを書いてGAS上でHello Worldします。次に、より実践的な使い方としてGmailをLINEに転送するコードをTypeScriptで実装します。

GAS用のCLIツールclaspを導入する

claspの導入方法は$ npm i @google/clasp -gを実行するだけです。

$ npm i @google/clasp -g

+ @google/clasp@2.3.0
added 160 packages from 92 contributors in 13.169s

これでclaspコマンドを実行できます。

$ clasp

clasp - The Apps Script CLI

Options:
  -v, --version
  -h, --help            output usage information

Commands:
  login [options]       Log in to script.google.com
  logout                Log out
  create [options]      Create a script
  ...

次にGoogleアカウントを使ってログインをします。コマンドは$ clasp loginです。

$ clasp login

Warning: You seem to already be logged in *globally*. You have a ~/.clasprc.json
Logging in globally...
🔑 Authorize clasp by visiting this url:
https://accounts.google.com/o/oauth2/v2/auth?access_type=xxx

Authorization successful.

Default credentials saved to: ~/.clasprc.json (/Users/Panda/.clasprc.json).

コマンドを実行すると新しくブラウザのタブが開きます。そこで、今回GASのプロジェクトを管理したいGoogleアカウントを選択しましょう。

ログインができたらタブを閉じてもOKです。

claspでTypeScriptを扱えるようにする

claspでTypeScriptを扱えるようにします。手順はclaspのGitHubを参考にしています。

まずはHello World用のプロジェクトを作成し、GASの型情報をインストールします。

$ mkdir hello-world
$ cd hello-world
$ npm i -S @types/google-apps-script
+ @types/google-apps-script@1.0.14
added 1 package from 3 contributors and audited 1 package in 2.471s
found 0 vulnerabilities

次に、tsconfig.jsonを作成します。tsconfig.jsonは以下のように定義します。

tsconfig.json
{
  "compilerOptions": {
    "lib": ["es2019"],
    "experimentalDecorators": true
  }
}

GASはJavaScriptのランタイムエンジンV8をサポートしているので、ES2019の機能を使うことができます(但し、ES modulesは除きます)。

これでTypeScriptのGASプロジェクト作成の準備ができました。

GASのプロジェクトを作成する

では、claspでプロジェクトを作成してみましょう。$ clasp createコマンドを実行します。

$ clasp create --title "Hello World" --type standalone
\ Creating new script: Hello World...
Created new standalone script: https://script.google.com/d/XXXXXXXXXXXX/edit
Warning: files in subfolder are not accounted for unless you set a '.claspignore' file.
Cloned 1 file.
└─ appsscript.json

プロジェクトルートにappsscript.jsonが作成されました。デフォルトのTimeZoneはAmerica/NewYorkなので、Asia/Tokyoに変更します。

appsscript.json
{
  "timeZone": "Asia/Tokyo",
  "dependencies": {
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8"
}

プロジェクトを作成できました。では、Hello Worldするコードを書いて、GASで実行してみましょう。

[基礎編]TypeScript + GASでHello Worldする

hello.tsを作成し、以下のように記述します。

hello.ts
const greeter = (person: string) => {
  return `Hello, ${person}!`;
}

function testGreeter() {
  const user = 'Panda 🐼';
  Logger.log(greeter(user));
}

GASでTypeScriptのコードを書いても動きません。しかし、claspがTypeScriptをJSにコンパイルしてくれるので、開発者はローカルでtsファイルを書くだけでいいのです。

このコードをGASにデプロイしてみましょう。コマンドは$ clasp pushです。

$ clasp push
└─ appsscript.json
└─ hello.ts
Pushed 2 files.

CLIでコマンドを実行すると、デプロイしたGASの画面を開くこともできます。$ clasp openを実行します。

$ clasp open
Opening script: https://script.google.com/d/XXXXXXXXXXXXXXXX/edit

ブラウザが立ち上がり、タブが開きました。

GASの画面

TypeScriptのコードはJavaScriptのコードにコンパイルされていることが確認できます。また、TypeScriptのバージョンが3.9.5であることもコメントから見て取れますね。

hello.js
// Compiled using ts2gas 3.6.2 (TypeScript 3.9.5)
var greeter = function (person) {
    return "Hello, " + person + "!";
};
function testGreeter() {
    var user = 'Panda 🐼';
    Logger.log(greeter(user));
}

testGreeterを実行して、ログを確認しましょう。

GASのログの画面

Hello, Panda 🐼!のと出力されています。testGreeterを実行できました。

[実践編]GmailをLINEに転送するGASを書く

次は、より実践的な例を紹介しましょう。

TypeScriptで記述していきます。「Gmailの新着メールをLINEに転送する by Google Apps Script」という記事で紹介したコードをTypeScript化していきます。

main.ts
const LINE_NOTIFY_TOKEN = PropertiesService
  .getScriptProperties()
  .getProperty('LINE_NOTIFY_TOKEN')
const ENDPOINT = 'https://notify-api.line.me/api/notify'

const FROM_ADDRESS = [''].join(' OR ')
const MINUTES_INTERVAL = 5

function main() {
  const notices = fetchNotices()

  if (notices.length === 0) {
    return
  }

  for (const notice of notices) {
    send(notice)
  }
}

function fetchNotices(): string[] {
  const now = Math.floor(new Date().getTime() / 1000)
  const intervalMinutesAgo = now - (60 * MINUTES_INTERVAL)
  const query = `(is:unread from:(${FROM_ADDRESS}) after:${intervalMinutesAgo})`

  const threads: GmailThread[] = GmailApp.search(query)

  if (threads.length === 0) {
    return []
  }

  const mails: GmailMessage[][] = GmailApp.getMessagesForThreads(threads)
  const notices: string[] = []

  for (const messages of mails) {
    const latestMessage: GmailMessage = messages.pop()
    const notice = `
--------------------------------------
件名: ${latestMessage.getSubject()}
受信日: ${latestMessage.getDate().toLocaleString()}
From: ${latestMessage.getFrom()}
--------------------------------------

${latestMessage.getPlainBody().slice(0, 350)}
`
    notices.push(notice)

    latestMessage.markRead()
  }

  return notices
}

function send(notice: string) {
  if (LINE_NOTIFY_TOKEN === null) {
    Logger.log('LINE_NOTIFY_TOKEN is not set.')
    return
  }

  const options: URLFetchRequestOptions = {
    'method': 'POST',
    'headers': {'Authorization': `Bearer ${LINE_NOTIFY_TOKEN}`},
    'payload': {'message': notice},
  }

  UrlFetchApp.fetch(ENDPOINT, options)
}

JSのコードと比較すると、getMessagesForThreadsの返り値が多次元配列になっていることが明確に意識できるのがとてもいいですね。

GASのGmailAppの型定義

また、型定義を確認するとgetPropertyでキーが存在しない場合はnullを返すことを知れたり、fetchのoptionsの型をURLFetchRequestOptionsに設定できました。

プログラムを型で縛ることは、実行時に発生するバグを未然に防ぐことに繋がります。

なお、今回参考にした型定義を抜粋は以下の通りです。

interface Properties {
  getProperty(key: string): string | null;
}

interface GmailApp {
  search(query: string): GmailThread[];
  getMessagesForThreads(threads: GmailThread[]): GmailMessage[][];
}

interface GmailMessage {
  getSubject(): string;
  getDate(): Base.Date;
  getFrom(): string;
  getPlainBody(): string;
  markRead(): GmailMessage;
}
interface UrlFetchApp {
  fetch(url: string, params: URLFetchRequestOptions): HTTPResponse;
}

interface HttpHeaders {
  [key: string]: string;
}
type HttpMethod = 'get' | 'delete' | 'patch' | 'post' | 'put';
type Payload = string | { [key: string]: any } | Base.Blob;

GASをローカルでTypeScriptで開発するメリット

GASをローカルで書くことができることは大きなメリットがあります。

  • GASに型注釈がつくことで、バグを生みにくくなる
  • 好きなエディタを使うことができる
  • 型定義にジャンプして、引数や返り値の型をチェックできる

特にIDEからショートカットキーを押すだけで型定義に飛べるので、返り値を確認するためにlogを吐き出したり公式ドキュメントを探す必要が無くなります。

このため、プログラムを組むスピードがTSがない場合と比べて爆速です。型定義はGASを書くスピードを上げるアクセルといっても過言ではないでしょう。

まとめ

コンパイラがあるプログラミング言語やTDDでもない限り、プログラムのデバッグ方法は「実行 → ログ確認 → 修正」が通常の流れです。

しかし、TypeScriptが型で縛ってくれるおかげで「実行 → ログ確認」のフィードバックループを回す回数は格段に減りました。

TypeScriptの素晴らしい開発体験をGASでも開発でも享受できるのは嬉しいことです。claspを開発し、TypeScriptに対応させてくれたGoogleのチームに感謝です。

Google Apps ScriptTypeScript
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

Stripe APIを使って、Subscriptions(定期払い)のステータスを集計する

この記事では、StripeのAPIを使って、Subscriptions(定期払い)のユーザーのステータスをGASで集計する方法をご紹介します。

StripeとはSaaSの決済サービスです。その中でも今回はSubscriptionsという定期払いに焦点を当て、Subscriptionsのstatusがactive(課金が有効)、trialing(トライアル)、また解約予定の数を集計します。

集計にはいくつか方法があるので、それぞれのメリットとデメリットを比較していきます。

後半では集計した数値をスプレッドシートに保存し、Slackに通知する方法をご紹介します。

それでは早速見ていきましょう。

関連記事

Subscriptionsの集計方法の検討

Subscriptionsの集計には3つの手段があります。Stripe SIGMA、ダッシュボードからCSVのエクスポート、そしてStripe APIをコールすることです。

以下、順番に検討していきましょう。

Stripe SIGMAでSQLを実行する

Stripe SIGMAは、Stripeに保存されているデータに対してSQLを発行できるサービスです。

Stripe SIGMA

SQLを書いて柔軟にデータを取得できるというメリットを持つ一方、SIGMAの利用は有料です。このため、今回は別の方法を探しましょう。十分に予算が確保されている場合は、データ集計に最適な手段と言えるでしょう。

料金体系は図示されていますので、お使いになる場合は参照してみてください。なお、SIGMAの利用は別途申請が必要になります。

ダッシュボードからCSVをエクスポートする

Stripeのダッシュボードから「定期支払い」を選択します。

そして、「エクスポート」というボタンをクリックすると、定期支払いのデータをCSVの形式でダウンロードできます。

Stripeのダッシュボード

CSVのままでは扱いにくいと思うので、Googleのスプレッドシートにインポートしましょう。スプレッドシートの関数sumifを使ってstatusを指定し、active, triallingの数を集計すれば目的を達成できます。

あるいは、CSVを読み込む簡単なプログラムを書いて、ローカルで実行することもできます。どちらもデータの加工は手軽にできるところがメリットです。

また、ダッシュボードにアクセスできる人なら誰でもエクスポートができるので、データ取得の仕事はエンジニア以外の方にも任せることができます。

しかし、データは毎日集計してこそ真価を発揮するものです。ご想像の通り、毎日Stripeのダッシュボードにアクセスして、手作業でエクスポートをするのは煩雑です。これがエクスポートする方法のデメリットです。

Stripe APIを利用する

StripeはAPIを公開しており、プログラムを通してデータを取得することは容易です。

Stripeは主要なプログラミング言語に対応したライブラリが手厚く揃っています。2020年6月現在、Go, Ruby, Node.js, PHP, JAVA, Python, .NET用のライブラリが公開されています。

これらの言語のStripe用ライブラリを利用できれば、Subscriptionsを集計することは容易です。

集計プログラムを定期実行するだけなので、実行場所はFaaSが適切です。LambdaかGoogle Apps Script(以下、GAS)が最適ですね。

今回は、集計した値をスプレッドシートに保存したいので、GASを活用します。

GASはJavaScriptを実行できますが、Node.jsのモジュールを利用できません。よってStripeのライブラリは利用できないため、エンドポイントにGETリクエストを送ってSubscriptionsのデータを取得します。

GASでSubscriptionのstatusを集計する

SubscriptionsのList APIをコールする

StripeのList Subscriptions APIを利用します。

以下はcurlを使ってSubscriptionsのデータを取得するコマンドのサンプルです。

curl https://api.stripe.com/v1/subscriptions \
  -u sk_live_XXXXXXXXXXXXXX: \
  -d limit=3 \
  -G

sk_live_XXXXXXXXXXXXXXは、シークレットキーです。Stripeのダッシュボードから取得できますが、このキーは絶対に外に漏れないように管理しましょう。

次にcurlのオプションを1つずつみていきましょう。

-u: basic認証。userにシークレットキーを指定します。
-d: key=valueのデータ。limit=3なので、最大3件取得します
-G: GETメソッド

これをJavaScriptで書き替えると以下のようになります。以下はGASのコードです。

const URL = 'https://api.stripe.com/v1/subscriptions'
const LIMIT = 3
const params = `?limit=${LIMIT}`

const SECRET_KEY = 'sk_live_XXXXXXXXXXXXXX'

const options = {
  headers: {
    Authorization: 'Basic ' + Utilities.base64Encode(SECRET_KEY)
  },
  method: 'GET'
}

UrlFetchApp.fetch(URL + params, options)

curlでもGASでも、返り値は以下のようになります。

{
  "object": "list",
  "url": "/v1/subscriptions",
  "has_more": false,
  "data": [
    {
      "id": "su_XXXXXXXXXXXXXX",
      "object": "subscription",
      "current_period_end": 1594980643,
      "customer": "cus_XXXXXXXXXXXXXX",
      "status": "active",
      ...
    },
    {...},
    {...}
  ]
}

(不要な値は省略しています。全ての値を確認したい場合はAPIドキュメントを参照してください。

1件目のstatusはactiveですね。statusactivetrialingの数をカウントしていきましょう。また、今回は解約予定の人の数もカウントします。

Subscriptionsのstatusをカウントするコード

main.js
// Stripe
const URL = 'https://api.stripe.com/v1/subscriptions'
// 1. StripeのシークレットキーをGASのプロパティから取得します
const SECRET_KEY = PropertiesService
    .getScriptProperties()
    .getProperty('SECRET_KEY')
// 1度のリクエストにおけるlimitの最大数の100を指定します
const LIMIT = 100

function fetchSubscriptionList(params) {
  const options = {
    headers: {
      Authorization: 'Basic ' + Utilities.base64Encode(SECRET_KEY)
    },
    muteHttpExceptions: true,
    method: 'GET'
  }
  const res = UrlFetchApp.fetch(URL + params, options)

  // レスポンスボディをオブジェクトに変換します
  return JSON.parse(res.getContentText())
}

function getSubscriptionStatusCounts() {
  let activeCount = trialingCount = cancelCount = 0
  // pagination
  let startingAfter = null

  while(true) {
    // 2. データが101件以上ある場合は、ページネーションで次の100件を取得します
    // starting_afterでカーソルを指定しましょう
    const params = startingAfter === null
        ? `?limit=${LIMIT}`
        : `?limit=${LIMIT}&starting_after=${startingAfter}`
    const subscriptions = fetchSubscriptionList(params)

    subscriptions.data.forEach(subscription => {
      // subscriptionのstatusに応じてカウント
      switch (subscription.status) {
        case 'active':
          activeCount += 1
          break;
        case 'trialing':
          trialingCount += 1
          break;
        default:
          break
      }

      // 3. キャンセル予約をしている人をカウント
      if (subscription.cancel_at_period_end){
        cancelCount += 1
      }

      // ページネーションのために最後のidを保持する
      startingAfter = subscription.id
    });

    // 4.paginationの最後でloopを抜ける
    if(!subscriptions.has_more) {
      break;
    }
  }

  return [activeCount, trialingCount, cancelCount]
}

以下、コードの解説をします。

  1. シークレットキーはスクリプトのプロパティに保存しています。[この手順は「Google Apps Scriptのプロパティに値を保存する」をご覧ください。](/posts/gas-slack-line-bot/#Google Apps Scriptのプロパティに値を保存する)
  2. 一度のリクエストで取得できるsubscriptionの件数は100件です。「定期支払い」の件数がそれ以上存在する場合、paginationの機能を使って残りのオブジェクトを取得しましょう。
  3. キャンセル予定の人のstatusはcanceledではありません。canceledはキャンセル済みの人のステータスだからです。このため、解約予約をしている人はcancel_at_period_endというbooleanを使ってカウントします。
  4. レスポンスオブジェクト内のプロパティhas_moreは、subscriptionオブジェクトが残っているかを示すbooleanです。全てのオブジェクトを取得したら、loopを抜けてエンドポイントにリクエストを送るのをストップしましょう。

なお、今回はキャンセル済みのSubscriptionsは集計対象に含まれていません。もし集計する場合は、paramsにstatus=calceledを追加してください。

スプレッドシートに集計した値を保存する

次に、取得した値をスプレッドシートに保存します。

const SPREADSHEET_ID = PropertiesService
  .getScriptProperties()
  .getProperty('SPREADSHEET_ID')

function addRow(counts) {
  const ss = SpreadsheetApp.openById(SPREADSHEET_ID)
  const sheet = ss.getActiveSheet()
  const lastRow = sheet.getLastRow()
  const data = [[
    Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd'),
    ...counts,
  ]]

  // 最終行に、日付と各種数値を追加
  sheet.getRange(lastRow + 1, 1, 1, 4).setValues(data)
}

変数countの中身が[160, 320, 8](順に課金ユーザー数、トライアル数、解約予定数)である場合、スプレッドシートの8行目のように行を追加します。

スプレッドシート

sheet.getLastRow()で最終行を取得しているので、GASを実行する度に自動で新しく行を追加できます。

Slackと連携して集計結果を通知する

最後にこのデータをSlackに通知しましょう。これでエンジニアだけでなく、プロダクトオーナー、マーケター、デザイナー、また部門長などあらゆる人が課金ユーザー数、つまりビジネスの進捗を把握できます。

const SLACK_WEBHOOK_URL = PropertiesService
    .getScriptProperties()
    .getProperty('SLACK_WEBHOOK_URL')

function sendSlack(counts) {
  const text = `
今日の全ユーザーの課金状態です。
課金ユーザー:${counts[0]}
トライアル:${counts[1]}
解約予定:${counts[2]}
次回課金予定数:${counts[0] + counts[1] - counts[2]}
`
  const data = {
    'attachments': [{text}],
  }
  const slackOptions = {
    'method' : 'POST',
    'contentType' : 'application/json',
    'payload' : JSON.stringify(data)
  }

  UrlFetchApp.fetch(SLACK_WEBHOOK_URL, slackOptions);
}

GASのトリガーをを設定し、毎日Slackに通知するbotにする

最後に、main関数を作りましょう。

function main() {
  const counts = getSubscriptionStatusCounts()
  // Spreadsheetに値を保存する
  addRow(counts)
  // Slackに送る
  sendSlack(counts)
}

毎日main関数を定期実行するために、トリガーを設定します。

これでbot化完了です!

まとめ

Subscriptionのstatusを集計するためにGoogle Apps Scriptでコードを記述しました。Stripeについての日本語の記述はまだまだ少ないので、本記事がお役に立てたら幸いです。

コードの全文を記載します

コードが断片的になって見通しが悪いので全体を記載します。ぜひ参考にしてみてください。

main.js
// Stripe
const URL = 'https://api.stripe.com/v1/subscriptions'
const SECRET_KEY = PropertiesService
    .getScriptProperties()
    .getProperty('SECRET_KEY')
const LIMIT = 100

// Spreadsheet
const SPREADSHEET_ID = PropertiesService
  .getScriptProperties()
  .getProperty('SPREADSHEET_ID')

// Slack
const SLACK_WEBHOOK_URL = PropertiesService
    .getScriptProperties()
    .getProperty('SLACK_WEBHOOK_URL')

function fetchSubscriptionList(params) {
  const options = {
    headers: {
      Authorization: 'Basic ' + Utilities.base64Encode(SECRET_KEY)
    },
    muteHttpExceptions: true,
    method: 'GET'
  }
  const res = UrlFetchApp.fetch(URL + params, options)

  // レスポンスボディをオブジェクトに変換します
  return JSON.parse(res.getContentText())
}

function getSubscriptionStatusCounts() {
  let activeCount = trialingCount = cancelCount = 0
  // pagination
  let startingAfter = null

  while(true) {
    // 2. データが101件以上ある場合は、ページネーションで次の100件を取得します
    // starting_afterでカーソルを指定しましょう
    const params = startingAfter === null
        ? `?limit=${LIMIT}`
        : `?limit=${LIMIT}&starting_after=${startingAfter}`
    const subscriptions = fetchSubscriptionList(params)

    subscriptions.data.forEach(subscription => {
      // subscriptionのstatusに応じてカウント
      switch (subscription.status) {
        case 'active':
          activeCount += 1
          break;
        case 'trialing':
          trialingCount += 1
          break;
        default:
          break
      }

      // 3. キャンセル予約をしている人をカウント
      if (subscription.cancel_at_period_end){
        cancelCount += 1
      }

      // ページネーションのために最後のidを保持する
      startingAfter = subscription.id
    });

    // 4.paginationの最後でloopを抜ける
    if(!subscriptions.has_more) {
      break;
    }
  }

  return [activeCount, trialingCount, cancelCount]
}

function addRow(counts) {
  const ss = SpreadsheetApp.openById(SPREADSHEET_ID)
  const sheet = ss.getActiveSheet()
  const lastRow = sheet.getLastRow()
  const data = [[
    Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd'),
    ...counts,
  ]]

  // 最終行に、日付と各種数値を追加
  sheet.getRange(lastRow + 1, 1, 1, 4).setValues(data)
}

function sendSlack(counts) {
  const text = `
今日の全ユーザーの課金状態です。
課金ユーザー:${counts[0]}
トライアル:${counts[1]}
解約予定:${counts[2]}
次回課金予定数:${counts[0] + counts[1] - counts[2]}
`
  const data = {
    'attachments': [{text}],
  }
  const slackOptions = {
    'method' : 'POST',
    'contentType' : 'application/json',
    'payload' : JSON.stringify(data)
  }

  UrlFetchApp.fetch(SLACK_WEBHOOK_URL, slackOptions);
}

function main() {
  const counts = getSubscriptionStatusCounts()
  addRow(counts)
  sendSlack(counts)
}
Google Apps ScriptStripe
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

Google Apps Scriptで議事録作成を自動化する

この記事では、Google Apps Script(以下、GAS)を使って毎週の議事録作成を自動化する方法を紹介します。

Google Driveに保存されているGoogle Documentをコピーするコードを解説した後、祝日は実行をスキップするコードとSlackに通知するコードを掲載します。

関連記事

GASのコードを書く

GASでフォルダやファイルを扱うにはDriveAppオブジェクトを利用します。

今回はGoogle Document20200613_🐼チーム_議事録をコピーして、20200620_🐼チーム_議事録というファイルを自動作成するというシーンを想定します。

Google DriveのファイルをコピーするGASのコードは下記です。

main.js
const FOLDER_ID = 'XXXXXXXXXXXXXXXX'

function copy() {
  // 1. コピー元のファイルが存在するフォルダを指定する
  const folder = DriveApp.getFolderById(FOLDER_ID)

  // 2. ファイル名は数値の降順なので、
  // イテレーションの先頭のものが最新のファイルになる
  const files = folder.getFiles()
  const file = files.next()

  // 3. 新規ファイル名を組み立てる
  const date = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd')
  const name = `${date}_🐼チーム_議事録`

  return file.makeCopy(name, folder)
}

FOLDER_IDは、コピーしたいファイルが格納されているフォルダのIDです。https://drive.google.com/drive/folders/XXXXXXXXXXXXXXXXのように、フォルダのIDはURL内に記載されています。

以下、コメントを記載している箇所の解説をします。

コピー元のファイルが存在するフォルダを指定する

DriveAppオブジェクトのgetFolderById(id)メソッドでフォルダを取得します。

**返り値はFolderオブジェクトです。**次にFolderオブジェクトを使って、このフォルダに存在するファイルを全て取得します。

フォルダに存在するファイル群から目的のファイルを取得する

FolderオブジェクトのgetFiles()メソッドでファイルを取得します。

**返り値はFileIteratorというイテレータです。**このイテレータの中にはFileオブジェクトが入っています。

例えば、フォルダに格納されている全てのファイルのファイル名を取得するコードは以下のようになります。

const files = DriveApp.getFiles();
while (files.hasNext()) {
  const file = files.next();
  Logger.log(file.getName());
}

Fileイテレータ内のファイルは数値の降順になっているため、ファイル名の先頭に日付を入れておくとイテレータの先頭に前回の議事録のファイルが入ります。

このため、今回はfiles.next()を一度実行するだけで対象のファイルを取得できます。

新規ファイル名を組み立てて、ファイルをコピーする

新しいファイル名は20200620_🐼チーム_議事録にします。

会議当日の日付が入るため、GASの便利オブジェクトUtilitiesformatDateを使って日付を組み立てます。

そして、filemakeCopyでコピーを作成します。第二引数には、ファイルを格納したいフォルダを指定します。

実務で使えるようにブラッシュアップする

これでGoogle Driveのファイルをコピーできました。さらに作成した議事録をSlackに通知する機能と、祝日なら議事録を作成しないようにしましょう。

コピーした議事録のURLをSlackに通知する

作成した議事録のURLをSlackのチャンネルに投稿しましょう。

const SLACK_WEBHOOK_URL = PropertiesService
    .getScriptProperties()
    .getProperty('SLACK_WEBHOOK_URL')

function send(url) {
  const text = `今日の議事録予定地です
${url}`

  const data = {
    "username" : 'Panda User',
    "icon_emoji": ':panda_face:',
    text,
  };

  const params = {
    "method": "POST",
    "contentType": "application/json",
    "payload": JSON.stringify(data),
  };

  UrlFetchApp.fetch(SLACK_WEBHOOK_URL, params)
}

Slackのチャンネルに対して発行されるWEBHOOK URLはGASのプロパティに格納しましょう。GASのプロパティは環境変数のようなものです。WEBHOOK URLは外に漏れないように管理しましょう。

GASで祝日の判定をして、祝日なら議事録を作成しない

祝日判定には、CalendarAppオブジェクトを使います。

function isHoliday() {
  const today = new Date();
  const calendars = CalendarApp.getCalendarsByName('日本の祝日');
  const count = calendars[0].getEventsForDay(today).length;
  return count > 0;
}

祝日を判定する仕組みは下記の記事で詳しく解説しています。

GASで日本の休日・祝日を判定する方法を解説します(Google Apps Script)

週次実行のトリガーを設定する

次は、議事録の作成を週次で実行する設定をしましょう。GASではトリガーを設定することで、毎週自動で関数を実行できます。

トリガーは関数単位で指定できるので、コード全文に記載しているmain関数を指定します。

GASのトリガーの設定方法は、「Google Apps ScriptからSlackとLINEを連携するbotを作る手順を紹介します」という記事で解説しています。

まとめ

これで議事録を自動でSlackに投稿できるようになりました!

Slackへの投稿

今回は日常に潜むルーティンワークの1つ、議事録の週次の作成を無くすためのGASを紹介しました。

「プログラマが知るべき97のこと」という本に「面倒でも自動化できることは自動化する」という章があります。

この章で説かれているようにルーティンワークはプログラムで自動化して、価値を生む活動のために時間を節約しましょう!

コード全文を記載します

今回使ったコードの全文を記載します。

main.js
const FOLDER_ID = 'XXXXXXXXXXXXXXXX'
const SLACK_WEBHOOK_URL = PropertiesService
  .getScriptProperties()
  .getProperty('SLACK_WEBHOOK_URL')

function main() {
  if (isHoliday()) {
    return
  }

  const file = copy()
  const url = file.getUrl()

  send(url)
}

function isHoliday() {
  const today = new Date();
  const calendars = CalendarApp.getCalendarsByName('日本の祝日');
  const count = calendars[0].getEventsForDay(today).length;
  return count > 0;
}

function copy() {
  const folder = DriveApp.getFolderById(FOLDER_ID)
  const files = folder.getFiles()
  const file = files.next()

  // 新規ファイル名を組み立てる
  const date = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd')
  const name = `${date}_🐼チーム_議事録`

  return file.makeCopy(name, folder)
}

function send(url) {
  const data = {
    "username": 'panda user',
    "icon_emoji": ':panda-face:',
    "text": `今日の議事録予定地です
${url}`,
  };
  const params = {
    "method": "post",
    "contentType": "application/json",
    "payload": JSON.stringify(data),
  };

  UrlFetchApp.fetch(SLACK_WEBHOOK_URL, params)
}
Google Apps Script
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer