レシピサービスのフロントエンドに CSS in JS を採用した話

こんにちは。技術部クックパッドサービス基盤グループのkaorun343です。我々のチームでは レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 にて紹介したとおり、レシピサービスを Next.js ベースの新システムへと移行しています。今回はこの新システムの CSS の話 です。

背景

クックパッドのレシピサービスを Next.js と TypeScript で置き換えはじめた当初、CSS については Next.js に標準で組み込まれているCSS in JS ライブラリである styled-jsx を使っていました。プロジェクトが大きくなりはじめたタイミングで 「CSS の技術選定を考えなおしてもいいかもしれない」とチームの中で話し合い、改めて技術選定をしました。

技術選定

結論として、本システムでは CSS in JS ライブラリのemotion を採用しました。emotion には css prop を使う @emotion/react(旧 @emotion/core) と、 styled-components ライクな記法が使える @emotion/styled がありますが、css prop を使う @emotion/react を採用しました。記法については React の style prop のような Object Styles ではなく、通常の CSS と同じように書くことができる、タグ付きテンプレートリテラルを用いた String Styles を採用しました。

npmtrends を見ると、一番ダウンロード数が多いのは @emotion/react の昔の名前である @emotion/core となっています(技術選定時は @emotion/core だったので、こちらの名前で比較)。しかし @emotion/styled が依存するライブラリであるため、@emotion/react 単体で使うケースはそう多くありません。したがって @emotion/styled と styled-components が広く使われていることがわかります。

f:id:kaorun343:20210313093352p:plain
CSS in JS ライブラリのダウンロード数

まず、ダウンロード数が多くないにも関わらず @emotion/react による css prop を採用した理由を説明します。

1 点目はカプセル化ができるという点です。CSS はグローバルな名前空間であるため名前が衝突し、他の要素に意図せずスタイルを当ててしまう可能性があります。

<main class="main">
  <section class="section">
    <h2 class="title">タイトル</h2>
  </section>
</main>
/* スタイルA */
.main {
  .title {
    color: green;
  }
}

/* スタイルB */
.section {
  .title {
    color: orange;
  }
}

例えばこの例では、 スタイル A とスタイル B がどちらも .title を指定しているため、 h2.title の文字色は 2 つの記述する順番に依存します(この例の順番だとオレンジ色になります)。 BEM などの命名規則を採用すれば、名前の衝突を避けることができますが、スタイル名の記述が長くなってしまう別の問題があります。 一方で emotion はスタイルごとに一意な ID が割り振られるため、セレクタ名が競合することはありません。したがって上記のような問題を防ぐことができます。

2 点目は静的解析がしやすいという点です。CSS Modulesstyled-jsx では、通常の CSS と同様にセレクタを記述して className にクラス名を書いていくことになります。これらの手法の問題点は、クラス名を間違っていたとしても実行するまで気づくことができないこと、未使用のスタイルを静的解析により検出することができないことです(前者については、通常は Web ブラウザに画面を表示しながら作業するためそれほど大きな問題ではありませんが)。一方 emotion は、ESLint や TypeScript コンパイラといった JavaScript 向けの静的解析ツールにより、未使用の CSS を簡単に検出できます。

3 点目はコードレビューのしやすさです。これは styled-components の記法との比較です。styled-components の記法では、スタイルが当てられた DOM 要素や、スタイルが上書きされた既存のコンポーネントがどれもパスカルケースのコンポーネントとなります。

// styled-components の記法
export const StyledComponent: FC = () => {
  return (
    <Wrapper>
      <IconWrapper>
        <BellIcon />
      </IconWrapper>
      <MainText>お知らせ</MainText>
      <RightText>10</RightText>
    </Wrapper>
  );
};

// css propによる記法
export const CssProp: FC = () => {
  return (
    <div css={wrapperStyle}>
      <div css={iconWrapperStyle}>
        <BellIcon />
      </div>
      <div css={mainTextStyle}>お知らせ</div>
      <div css={rightTextStyle}>10</div>
    </div>
  );
};

この2つのコンポーネントを見ていただければわかると思いますが、パスカルケースの JSX タグを見たときに、それが「機能を持つコンポーネント」なのか、もしくは「スタイルが当てられたコンポーネント」なのか、ひと目で判断しにくいです。この理由から styled-components の記法は不採用となりました。

次に String Styles を採用した理由を説明します。

Object Styles では font-size などのケバブケースの CSS プロパティを fontSize のようにキャメルケースで記述しなければなりません。一方で String Styles は通常の CSS と同じように記述できます。通常の CSS の記述に慣れ親しんでいるメンバーが多いため、読みやすさや書きやすさの点から String Styles を採用しました。

const objectStyle = css({
  fontSize: "30px",
  color: "blue",
});

const stringStyle = css`
  font-size: 30px;
  color: blue;
`;

emotion の機能

emotion は stylis という CSS ライブラリが基盤となっており、以下の機能を提供してくれます。

  • ベンダープレフィックスの自動付与
  • セレクタのネスト
  • メディアクエリのサポート
  • テーマ機能のサポート

CSS を返す関数を定義することで、動的に スタイルを生成できます。

const getTextareaStyle = (isValid: boolean) => css`
  border: solid 1px ${isValid ? "green" : "red"};
`;

また別の CSS オブジェクトを元に新しいスタイルを定義できます。

const buttonBaseStyle = css`
  width: 100%;
  color: red;
`;

/**
 * const buttonSpecialStyle = css`
 *   width: 100%;
 *   color: red;
 *   font-size: 16px;
 * `;
 */
const buttonSpecialStyle = css`
  ${buttonBaseStyle}
  font-size: 16px;
`;

それ以外にも @emotion/babel-plugin により Babel のトランスパイル時にコードの最適化をおこない、不要な改行やインデントの削除などをおこなうことができます。

styled-jsx から emotion への置き換え

この 2 つのライブラリは共存させることが可能だったため少しずつ emotion へ置き換えることができました。移行の過程で CSS の記述を大きく変える必要がなく、プロジェクトが始まって間もない頃で変更箇所が少なかったことから、数回のプルリクエストで移行を完了しました。

emotion を用いた React コンポーネントの書き方

こちらがコンポーネントのコード例です。

import { css } from "@emotion/react";
import { FC, useEffect } from "react";
import { StepListItem } from "./StepListItem";
import { Step } from "./Step";

type Props = {
  steps: readonly Step[];
};

export const StepList: FC<Props> = ({ steps }) => {
  useEffect(() => {
    //
  }, []);

  return (
    <section css={rootStyle}>
      <h2 css={sectionTitleStyle}>手順</h2>
      {stpes.map((step) => (
        <StepListItem key={step.id} step={step} />
      ))}
    </section>
  );
};

const rootStyle = css`
  padding: 8px;
`;

const sectionTitleStyle = css`
  font-size: 16px;
  &:hover {
    text-decoratoion: underline;
  }
`;

CSS in JS では基本的に 1 つのファイルにコンポーネントの HTML、CSS、JavaScript をすべて詰め込みますが、それらを記述する順番が読みやすさに強く影響します。そこで本システムでは CSS をファイルの一番下に配置しました。

CSS は JavaScript よりも HTML (JSX) と密接に関連しています。また React の関数コンポーネントは関数の前半に React フックを書き、後半に JSX タグを書く構成がほとんどです。もし CSS の定義が関数コンポーネントの前に置かれていると、 React フックの行数が増えるほど CSS と JSX が離れてしまい、コードが見にくくなってしまいます。その点今回の配置では、CSS の変数の使用箇所が定義よりも前にくる違和感はありますが、 CSS と JSX が近い位置に配置されてコードが読みやすくなりました。 Vue.js のスタイルガイドにおいても <style></style> を一番下に配置するように推奨しています。

stylelint の導入

CSS の宣言そのものに対する静的解析ツールの stylelint もあわせて導入しました。存在しないプロパティや誤った値の指定を検出してくれます。stylelint 単体では emotion のコードを検証してくれないため、emotion の CSS 宣言をチェックするために、 stylelint-processor-styled-components を利用しています。こちらは名前に styled-components とある通り、もとより styled-components 向けのライブラリです。しかしemotion に対しても問題なく使えます。

Emotion には Next.js の SSR 環境 に起因する問題がありました。これは SSR 環境において:nth-child などの *-childが使えないという問題です( https://github.com/emotion-js/emotion/issues/1178 )。SSR 環境では <style></style> タグが途中に挿入される場合があり、正しくスタイルを当てられない可能性があるためです。

そこで、 *-child の代わりに *-of-type を使うように stylelint にプラグインを追加することで対処しました。

const stylelint = require("stylelint");

const ruleName = "local/unsafe-pseudo-classes";
const messages = stylelint.utils.ruleMessages(ruleName, {
  expected: (unsafePseudoClass) => {
    return `The pseudo class "${unsafePseudoClass}" is potentially unsafe when doing server-side rendering. Try changing it to "${unsafePseudoClass.replace(
      "-child",
      "-of-type"
    )}".`;
  },
});

module.exports = stylelint.createPlugin(ruleName, function (primaryOption) {
  return function (postcssRoot, postcssResult) {
    const validOptions = stylelint.utils.validateOptions(
      postcssResult,
      ruleName,
      {
        actual: primaryOption,
        possible: [true],
      }
    );

    if (!validOptions) {
      return;
    }

    postcssRoot.walkRules((rule) => {
      const unsafePseudoClass = /:(?:first|nth|nth-last)-child/g.exec(
        rule.selector
      )?.[0];

      if (unsafePseudoClass) {
        stylelint.utils.report({
          message: messages.expected(unsafePseudoClass),
          node: rule,
          result: postcssResult,
          ruleName,
        });
      }
    });
  };
});

module.exports.ruleName = ruleName;
module.exports.messages = messages;

テストについて

本システムでは JestReact Testing Library によるコンポーネントのテストをおこなっており、スナップショットテストも活用しています。

@emotion/babel-preset-css-prop を利用することで、CSS の記述を展開してくれます。そのためスナップショットで CSS に変更がないか確認できます。以下にコンポーネントのスナップショットテストの例を示します。

/** @jest-environment jsdom */
/** @jsx jsx */
import { css, jsx } from "@emotion/react";
import { render } from "@testing-library/react";
import { FC } from "react";

const MyComponent: FC = () => {
  return (
    <div>
      <button type="button" css={buttonBaseStyle}>
        Base Style
      </button>
      <button type="button" css={buttonSpecialStyle}>
        Special Style
      </button>
    </div>
  );
};

const buttonBaseStyle = css`
  width: 100%;
  color: red;
`;

const buttonSpecialStyle = css`
  ${buttonBaseStyle}
  font-size: 16px;
`;

it("renders component", () => {
  const { container } = render(<MyComponent />);
  expect(container.firstChild).toMatchInlineSnapshot(`
    .emotion-0 {
      width: 100%;
      color: red;
    }

    .emotion-1 {
      width: 100%;
      color: red;
      font-size: 16px;
    }

    <div>
      <button
        class="emotion-0"
        type="button"
      >
        Base Style
      </button>
      <button
        class="emotion-1"
        type="button"
      >
        Special Style
      </button>
    </div>
  `);
});

将来的には画像回帰テストも導入して、より堅牢なシステムの構築を検討しています。

さいごに

今回はレシピサービスの新システムにおける CSS の話を紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を進めていきます。この取り組みを一緒に進めてくれる仲間を募集していますので、興味のある方はぜひご気軽にご連絡ください。

Xcode12時代のCarthageで起こった問題とXCFrameworkへの移行

モバイル基盤部のhiragramです。こんにちは。

私たちは、iOS版クックパッドアプリの開発において、CocoaPodsとCarthageを併用して依存ライブラリを管理しています。しかし、Xcode12の時代がやってきて、Carthageによる依存ライブラリのビルドに問題が生じました。この記事では、どのような問題なのか、そしてどのように対処したのかを紹介します。

3行まとめ

  • Xcode12とCarthageの組み合わせで問題が生じた
  • CarthageからSwiftPMへの移行を模索したが、断念した
  • 2021年2月にリリースされたCarthage 0.37.0でXCFrameworkが正式にサポートされたので、それに移行した

Xcode12と、当時のバージョンのCarthageの組み合わせで生じる問題

Carthageは、中央集権的なCocoaPodsとは対照的に、gitリポジトリを直接指定する分散型のパッケージ管理ツールです。リポジトリに含まれるライブラリのプロジェクト設定を使って、iOSアプリに組み込めるフレームワークをビルドします。Mac上で動くiOSシミュレータと実機の両方で使えるようにするため、それぞれのCPUアーキテクチャのためのバイナリを lipo というツールで1つのバイナリにまとめて、XXX.framework というファイルを作ります。lipoで作られた、複数のアーキテクチャ向けのバイナリを含むものはファットバイナリと呼ばれます。

この仕組みはXcode11以前から用いられてきましたが、Xcode12のツールチェインを使って、従来の方法でライブラリをビルドしようとするとエラーが出て失敗するようになりました。これはXcode12になって、iOSシミュレータ用のバイナリが、従来のIntel製CPUのx86_64用バイナリに加えてApple Siliconのarm64用バイナリを含むようになったことに起因します。iPhone実機用とApple Silicon上のシミュレータ用のバイナリがいずれもarm64用で、lipoの制約によりその両方を1つのファットバイナリに含めることができないためです。

Carthage builds fail at xcrun lipo on Xcode 12 beta (3,4,5...) · Issue #3019 · Carthage/Carthage

こちらのissueでは、シミュレータ向けのビルドをするときに EXCLUDED_ARCHS にarm64を指定することで、 "Apple Silicon上で動くiOSシミュレータ" 向けのコードをバイナリに含めないようにしてarm64(実機用)との衝突を防ぐ、というワークアラウンドが紹介されています。Xcodeのbetaが進むうちに根本的な対処法が見つかるだろうと楽観していましたが、Xcode12が正式にリリースされて以降もこのワークアラウンドが必要であるということがわかりました。そのため、クックパッドアプリもこのワークアラウンドを導入して開発/リリースを続ける必要がありました。

しかし、ワークアラウンドはあくまでその場しのぎですので、抜本的な対応として何をどうするべきか検討することにしました。

Swift Package Manager移行の模索

このセクションで書かれているのは2020年12月時点での状況です。

Carthageに囚われずパッケージ管理について改めて見つめ直す良い機会と捉えて、根本対応の手段を検討しました。その中で、XcodeのGUI上で扱うSwift Package Managerが候補にあがりました。サードパーティの依存管理ツールをハイブリッドで運用するよりも、公式が用意したもののほうが色々と都合がよいに違いありません。

Swift Package Manager(以下SwiftPM)は、Appleが提供する依存パッケージの管理ツールです。Xcode11からSwiftPMの機能が統合され、プロジェクトにSwiftPMのパッケージを含めることができるようになりました。Carthageで導入していた外部ライブラリを、SwiftPM経由で入れるように出来ないかを模索しました。

断念

結論から述べると、SwiftPMへの移行を断念しました。私たちの開発スタイルやプロジェクト構成にマッチしない都合があったからです。その中でもクリティカルだった、ユニットテストに関する問題を紹介します。

RxTestを使ったテストがビルドできない

クックパッドアプリではユニットテストの一部でRxTestを利用しており、リモートブランチを更新するごとにCI環境でテストがビルド/実行されます。しかし、RxSwiftをSwiftPMで導入したときに、RxTestを使っているテストコードがビルドできませんでした。シンボルの重複が起きているというエラーが出て、プロジェクトのビルド設定を変えたりアンブレラフレームワークを挟んだりしても解決できませんでした。RxSwiftのissueでも、多くの人がテストコードのビルドやライブラリのリンクなどについて問題を報告していて、SwiftPM側に問題があるという見解が示されています。

We unfortunately can't support SPM properly due to a critical known bug in SPM affecting many multi-target repos: https://bugs.swift.org/browse/SR-12303

- Migrating from Cocoapods to SPM, UITest target "missing required module 'RxCocoaRuntime'" · Issue #2210 · ReactiveX/RxSwift

上述のコメントからリンクされているバグチケットは報告から半年以上たった現時点でもSwiftPM開発メンバーからのリアクションが無く、私が手元で何かちょっと頑張った程度ではどうにもならないだろうと考え、SwiftPMへの移行を断念しました。

Carthage 0.37.0で新しく追加されたXCFrameworkへの移行

SwiftPMへの移行を模索していたのと同じ時期に、CarthageのリポジトリではXCFrameworkをサポートするための開発が進められていました。 XCFrameworkとは、Xcode11からサポートされた、フレームワークを配布するための新しい構造です。従来の形式では、複数のアーキテクチャのためのバイナリを1つのファットバイナリに統合していましたが、XCFrameworkは単体のプラットフォームのためのフレームワークを複数含む形式です。lipoによって1つのファットバイナリに統合する必要が無くなったため、記事の冒頭で述べたarm64のバイナリが衝突してしまう問題を回避できます。

XCFrameworkの詳細については、WWDC19のトークを御覧ください。

Binary Frameworks in Swift - WWDC19

Carthageでは0.37.0から、 --use-xcframeworks というオプションをつけることでXCFrameworkとしてビルドするようになりました。クックパッドアプリのプロジェクトでは0.37.0を使うことにして、Carthageで入れるライブラリはすべてXCFrameworkにすることにしました。

また、XCFrameworkの場合は従来Build Phaseに足す必要があった copy-frameworks が不要になります。もともと copy-frameworksは、AppStoreへサブミットするときにiOSシミュレータ向けのバイナリを含んでいると機械的にリジェクトされてしまうことに対する回避策でしたが、XCFrameworkは内部でプラットフォームが区別されているため、Xcodeの標準のコピーで事足りるようです。

クックパッドアプリではプロジェクトファイルをgit管理せずXcodeGenで生成していますが、XcodeGenでも従来のフレームワークと同じようにXCFrameworkのリンクを記述できます(クックパッドアプリ開発におけるXcodeGenの活用については、こちらの記事をごらんください)。XcodeGenのymlで、carthage というキーでフレームワークを指定していた所を、 framework というキーで、Carthageのビルドディレクトリにあるxcframeworkファイルを指定するだけです。

Build PhaseのLink Binary With Librariesの欄で、このように従来のフレームワークと同じ様にxcframeworkが指定されています(XcodeGenを使わずにプロジェクトファイルを編集する方は、この様にすれば動くはずです😉)。

f:id:hiragram:20210309145211p:plain

lipo時代のワークアラウンドとして行われていた、arm64用バイナリの除外も必要ないので、M1プロセッサのMac上で動くシミュレータでもアプリを動かすことが出来ます。クックパッドアプリはすでにワークアラウンドを削除しXCFrameworkに移行したバージョンが毎週リリースされており、この移行による問題は今の所報告されていません(クックパッドアプリ開発における毎週の自動サブミットについては、こちらの記事をごらんください)。

ただし、XcodeやCarthageにおけるXCFrameworkのサポートがまだ成熟していないからか、キャッシュの有無の判定が正しくないことがあります。例えばCarthage側でライブラリを更新してバージョンが変わったとき、XcodeやCarthageのキャッシュを一度クリーンしないと新しいXCFrameworkを使ってくれないことが多いです。原因はまだ調べられていませんが、おおよそ以下のことをやると正しくアプリがビルドできるようになります。

  • Xcodeを再起動する
  • Xcodeで Clean Build Folder をする
  • Carthage/Build を削除して再ビルドする

まとめ

iOS版クックパッドアプリでは、Carthageで管理されていたライブラリをXCFrameworkとして扱うようになりました。また、その過程で、SwiftPMなどの別のアプローチについても検証するよい機会となりました。

ちなみに、Xcode12.5 Beta2から、Xcode上のSwiftPMで外部ライブラリをDynamic Frameworkとしてビルドできるようになりました。これによって、先述のRxTestの問題が解決されているかもしれません(まだ未検証です😄)。モバイル基盤部では今後もビルド環境をモダンに保つための取り組みを続けていきます。このような領域に興味がある方は、ぜひ以下のリンクからご応募ください!

info.cookpad.com

Google Apps Script の拡張サービスの TypeScript 用型定義ファイルの自動生成

こんにちは、メディアプロダクト開発部の後藤(id:mtgto)です。

今回は Google Apps Script の28個の拡張サービスについて、 TypeScript 用の型定義ファイル (@types/google-apps-script) を、Web エディタのオートコンプリートマクロ用のデータから自動生成するプログラムを作成した話を紹介します。

Google Apps Script の紹介

読者の皆様はGoogle Apps Scriptはご存知でしょうか。名前は聞いたことがあるけど使ったことはあまりないという方が多いでしょうか。

Google Apps Script を使うことでドキュメント、スプレッドシート、スライド、フォームといった Google サービスのデータの取得・更新などを ECMAScript のプログラムから行うことができます。 例えば、

  • スプレッドシートのセルを読み込んでドキュメントに出力
  • Drive のファイル一覧を読み込んでスプレッドシートにリンク付きで出力
  • 外部 API にアクセスするスクリプトを一定周期ごとに動かす

このようなことを無料で行うことができます1

クックパッドでは Google Workspace をオフィススイートとして広く使用しており、スプレッドシートやドキュメントの自動化・マクロ実装などに Google Apps Script が利用されています。

Google Apps Scriptを普段の作業の改善に使うことも可能です。たとえば定例のテンプレートを google docs で管理してておき、次回の定例前にApps Scriptで雛形を追加する、といったこともECMAScriptを少し書くことで可能です。

私は以前Google Apps Scriptを利用して、期間と会議室の候補を入れると参加者の予定から空き時間&空き会議室を探してくれるウェブアプリを作りました。

f:id:mtgto:20210308214802p:plain
会議時間、参加者、会議室から、全員の空き時間と空き会議室を検索

f:id:mtgto:20210308215018p:plain
検索結果をクリックし、タイトルと説明を埋めて「登録」を押すとGoogle Calendarに予定が作られます

英語版のみですが、Codelabs に概要を知るためのチュートリアルも用意されています。 ざっくり Google Apps Script でできることの概要を知りたい方はちょうどいいかもしれません。

https://developers.google.com/apps-script/quickstart/fundamentals-codelabs?hl=ja#getting_started

Google Apps ScriptをTypeScriptで開発したい

Google Apps Scriptは前述の通り、ECMAScriptで記述する必要があります。 Webエディタも用意されていますが、ユニットテストやトランスパイルを必要とする場合、gitバージョン管理をしたいなどの場合にはGoogle謹製の clasp を使うことでCLIからの開発が実現できます。

clasp + TypeScriptでGoogle Apps Scriptのプログラムを書く場合には、@types/google-apps-script npmパッケージを使うことでGoogle Apps ScriptのAPI定義を利用できます。

標準サービス (Built-in services) と拡張サービス (Advanced services)

Google Apps Script で利用できる Google のサービスには標準サービス (Built-in services) と拡張サービス (Advanced services) という分別があります。

標準サービスのうちでもCalendar, Slides, Driveなど様々なGoogleサービスを利用できますが、さらに拡張サービスを使うことでGoogleの公開APIをECMAScriptから利用することが可能になります。

拡張サービスじゃないとできないことの例としては、

  • 自分以外のメンバーのカレンダーの予定を取得
  • 組織内のメンバーや会議室の情報を取得 (Admin Directory)
  • YouTube、Analytics、BigQuery などの標準サービスの対象外サービスの公開APIの利用

などがあります。

先程紹介したメンバーと会議室の空き時間を調整するアプリでは自分以外のメンバーの予定を調べないとだめなので、拡張サービスの利用がどうしても必要でした。

標準サービス用のTypeScriptの型定義は id:motemen によってAPIリファレンスから自動生成することで用意されていたのですが、拡張サービスの型定義は残念ながら用意されていませんでした。

そこで私が拡張サービスの型定義の自動生成に挑戦することにしました。

https://github.com/mtgto/dts-google-apps-script-advanced

Webエディタの自動補完定義からの自動生成

Google Apps Scriptで利用できる拡張サービスの数は2021年現在 28、利用できる関数の数は 2913 にもなります。

拡張サービスは標準サービスと違って API ドキュメントも公開されていないためスクレイピングによるAPI定義の取得もできません。 そこでWebエディタの自動補完マクロ用に用意されているJSONPデータに注目しました。

Webエディタでは拡張サービスであっても関数一覧が自動補完されます。

f:id:mtgto:20210308215049p:plain
GAS のエディタでの自動補完

自動補完の実現のために拡張サービスごとの関数やクラス構成はJSONPをWebエディタが読み込むことで実現しているようでした。例えば拡張サービスのCalendarの自動補完JSONPですと、

{
  "1": {
    "1": "Calendar_v3",
    "2": [
      {
        "1": "Acl",
        "2": "Calendar_v3.Calendar.V3.Collection.AclCollection",
        "3": 1
      },
      {
        "1": "CalendarList",
        "2": "Calendar_v3.Calendar.V3.Collection.CalendarListCollection",
        "3": 1
      },
      {
        "1": "Calendars",
        "2": "Calendar_v3.Calendar.V3.Collection.CalendarsCollection",
        "3": 1
      },
      ...
      {
        "1": "Events",
        "2": "Calendar_v3.Calendar.V3.Collection.EventsCollection",
        "3": 1
      }
    ],
    "3": [
      {
        "1": "newAclRule",
        "2": "Calendar_v3.Calendar.V3.Schema.AclRule",
        "6": "Create a new instance of AclRule"
      },
      ...

このようなデータが用意されています。2 このJSONをダウンロードし、プログラム内部でTypeScriptのnamespaceとinterfaceの構造に変換し、最後にd.tsファイル形式で出力することができるようにしました。

namespace Calendar {
    namespace Collection {
        interface AclCollection {
            // Returns an access control rule.
            get(calendarId: string, ruleId: string): Schema.AclRule;
            // Returns an access control rule.
            get(calendarId: string, ruleId: string, optionalArgs: object, headers: object): Schema.AclRule;
        }
    }
}

Google Apps Scriptの中がJavaScriptではないなにかで書かれているためか、基本データ型として number の代わりに Integer となっていたりするのでそのような型の変換を行うなどしてTypeScriptの型定義として忠実に再現しています。残念ながらJSONデータの中で名前しか公開されてないデータ構造を関数のレスポンスの型として使っていたりするので、そこは諦めて any で定義しています。

私が最初に型定義ファイルの自動生成プログラムを書いたときには自分が利用したいCalendarとAdmin Directoryにだけ対応していればよかったので、その2つだけDefinitelyTypedにPull Requestを出してマージしてもらいました。 そのあとGoogle Apps Scriptの中の人と話したりしてなんやかんやあり、結局全ての拡張サービスに対応できるようにプログラムの修正をしました。

まとめ

Google Apps Scriptの拡張サービスのTypeScriptの型定義をWebエディタの自動補完用のデータから自動生成するプログラムを書いた話を紹介しました。

Google Workspaceをがっつり利用している会社や組織の場合、Google Apps Scriptの標準サービスだけでも色々なことができることはあると思いますが、さらに拡張サービスを使うことで実はこんなこともできる、みたいな展開があるかもしれません。 また小さいスクリプトをGoogle Apps ScriptのWebエディタから書くのもいいですが、clasp + @types/google-apps-scriptを使うことでCLIでのTypeScriptでの開発もしやすくなっています。

QiitaのGoogle Apps Scriptタグ にもたくさん記事が投稿されていますので興味のある方はご覧ください。

クックパッドでは仲間を募集しています

クックパッドではモダンなフロントエンドの基盤作りレガシーなフロントエンドからの移行を始め、web開発に力を入れています。もしこの記事を見て興味がありましたらお気軽にお問い合わせください。

https://info.cookpad.com/careers/


  1. 注意:無料なのはQuota の範囲内の場合です。

  2. Googleアカウントでログインしてないとダウンロードできないため、28サービス分のJSONPのダウンロードを手動でやる羽目になって地味に面倒でした。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/