Stanby Tech Blog

求人検索エンジン「スタンバイ」を運営するスタンバイの開発組織やエンジニアリングについて発信するブログです。

Google Apps Script を TypeScript に移行した話

株式会社スタンバイでフロントエンドエンジニアをしている川野です。 フロントエンドエンジニアという役割を担っていますが、最近では開発者体験や開発生産性というところに興味があり、そのあたりの改善にもよく取り組んでいます。

はじめに

私たちのチームでは、Google Apps Script (GAS) を利用して、非エンジニアの人たちがシステムにスプレッドシートのデータをアップロードできる仕組みを構築しています。 GAS は手軽に開発でき、プロジェクト開始当初の小さな要求を満たすには十分なものでした。

しかし、プロジェクトが進むにつれ次第に要件が増えていき、GAS で対応するのが大変になるほど複雑になってきました。 また GAS では、型の恩恵を受けることが難しかったり、エディタのサポートが満足いくものでなかったりし、増大していく複雑さに立ち向かうのが難しくなってきました。

このような課題を解決し、開発者体験や開発生産性を向上させるために、GAS から TypeScript への移行を決めました。 また、あわせてソフトウェアアーキテクチャも刷新しました。

その中で得られた学びや気づきなどをいくつか紹介します。

GAS から TypeScript への移行

TypeScript に移行するメリット

TypeScript に移行することで次のようなメリットが得られます。

  • 型の恩恵を受けることができる。
  • npm パッケージを利用できる。
    • ESLint で静的解析できる。
    • Prettier でコードフォーマットできる。
    • その他便利なライブラリを利用できる。
  • テストを書くことができる。
  • 手慣れたエディタで開発できる。

いくつか挙げましたが、いつもの TypeScript での開発と同様の開発者体験を得られるようになります。 GAS アプリケーションの開発において、これは大きなメリットだと考えられます。

TypeScript で開発するには

TypeScript で開発したものは、GAS のプラットフォーム上にデプロイする必要があります。 clasp という Google が開発している CLI ツールがあるので、それを導入する必要があります。 そして後述の理由により、TypeScript のコードを一度 JavaScript にビルドする必要があります。

私たちのプロジェクトでは、次のような npm scripts を用意し、ビルドとデプロイを実行できるようにしました。

{
  "scripts": {
    "build:<feature>": "vite build -c src/features/<feature>/vite.config.ts",
    "deploy:<feature>": "clasp -P <output>/<feature> push"
  }
}

後述していますが、私たちのプロジェクトでは Package by Feature を採用しています。 そのため、それぞれの機能ごとに vite.config.ts が用意されており、そこに <output> のパスが記述されています。

clasp を使うときの注意点

clasp は、TypeScript のコードを GAS のコードに変換する機能を持っていますが、注意すべき制約があります。 それは、import/export 構文を扱うことができないことです。 そのため、デプロイする前に TypeScript のコードをビルドする必要があります。

ビルドには Vite を利用しました。 (将来的に凝った UI を作りたくなったときのことも視野に入れて。)

ビルドするときの注意点

GAS にはトリガーと呼ばれる、組み込みの予約済み関数があります。 たとえば、onOpendoGet といった関数です。

このような関数は、TypeScript のコード上からは参照されません。 そのため、ビルド時のツリーシェイキングが有効になっていると、これらの関数が削除されてしまい、正常に動作しなくなります。 この問題に対して、専用の Vite のプラグインを作ることで解決しました。

import fs from "fs/promises";

import type { Plugin } from "vite";

interface Options {
  inputDir: string;
  outputDir: string;
}

export const keepGasTrigger = ({ inputDir, outputDir }: Options): Plugin => {
  const GAS_TRIGGER = [
    "onOpen",
    "onInstall",
    "onEdit",
    "onSelectionChange",
    "doGet",
    "doPost",
  ];

  // 1. トリガー関数を利用するダミーコードを生成する。
  const dummyCodes = GAS_TRIGGER.map(
    (trigger) => `void ${trigger}.name.toString();`
  ).join("\n");

  return {
    name: "vite-plugin-keep-gas-trigger",
    config: (config) => {
      return {
        ...config,
        build: {
          rollupOptions: {
            input: `${inputDir}/main.ts`,
            output: {
              dir: outputDir,
              entryFileNames: "[name].js",
              format: "commonjs",
            },
          },
        },
      };
    },
    transform: (code, id) => {
      // 2. ダミーコードをコードの末尾に追加する。
      if (id.includes("main.ts")) return [code, dummyCodes].join("\n");

      return code;
    },
    closeBundle: async () => {
      const filePath = `${outputDir}/main.js`;
      const code = await fs.readFile(filePath, "utf-8");

      // 3. ビルド後のコードからダミーコードを削除する。
      const transformed = code.replace(`${dummyCodes}\n`, "");

      await fs.writeFile(filePath, transformed, "utf-8");
    },
  };
};

このように、ビルド前にトリガー関数を利用するダミーコードを追加し、ビルド後にそのダミーコードを削除するという、けっこう力技なことをしています。 本当にこれでいいのか?という疑問が拭えないので、良い解決策が見つかれば、乗り換えたいと思っています…

ソフトウェアアーキテクチャの刷新

どのように刷新したか

TypeScript への移行に伴い、ソフトウェアアーキテクチャも大幅に刷新しました。 というのも、そのまま移植したのでは複雑さや変化していく要件に立ち向かうことができないと思ったからです。 ソフトウェアアーキテクチャを見直すことで、より保守性の高いコードを書くことができるようになり、開発者体験や開発生産性を向上させることができると考えました。

アーキテクチャは、クリーンアーキテクチャ系ベースにし、以下のようなディレクトリ構成にしました。

src
└── features
    └── awesome_feature     # スプレッドシート単位で機能を作成する
        ├── controller      # 外界とのやり取りを扱うコントローラを格納する
        ├── core            # サービスやモデルなどビジネスロジックを扱うものを格納する
        ├── spreadsheet     # スプレッドシートに関するものを格納する
        ├── main.ts         # エントリーポイント(トリガー関数の記述等を行なっている)
        └── vite.config.ts  # Vite の設定ファイル(本機能のビルド用)

spreadsheetcontrollercore など混ざって密結合しないように、外からコンストラクタを経由して依存注入するようにしました。 また、Package by Feature を採用し、機能ごとにディレクトリを分ける構成にしました。 こうすることで責務が整理され、コードの保守性が高まり、コードの読み書きがしやすくなると考えたからです。

スプレッドシートの扱い

今回の要件では、データをアップロードする前にバリデーションを行う必要があります。

スプレッドシートはデータベースのようなもので、今まで Repository パターンを使ってデータを取得するものだと思っていました。 しかし、その考え方だと、Repository から取得したデータに対してバリデーションを行う必要が出てきます。 個人的に、Repository から取得したデータに対してバリデーションを行うのは、違和感のある実装でした。 データベースにはバリデーション済みの安全なデータが格納されているイメージがあったからです。

そこで考え方を変え、スプレッドシートのデータをフォーム入力のようなもの、とみなすことにしました。 スプレッドシートのデータを、入力値としてコントローラで取得し、バリデーションを行うようにすることで、より自然な実装にできました。

同じスプレッドシートでも、データの扱い方次第では責務が異なり、それを見極めて適切なレイヤーに処理を書くことが重要だと感じました。

まとめ

Google Apps Script の開発環境を刷新し、TypeScript へ移行しました。 その際に、ソフトウェアアーキテクチャも刷新し、責務を整理することによって保守性が高まり、コードの読み書きがしやすくなりました。

今後も、開発者体験や開発生産性を向上させるために、このような取り組みに積極的に取り組んでいきたいと考えています。

スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com