はじめに
Google Apps Script(GAS)でスプシのツールを作る際、セルのやり取りなどが煩雑になりがちですが、実は環境をしっかりと構築すればクリーンなアーキテクチャで書くことが可能です!
今回はその方法を紹介したいと思います
例として郵便番号から住所検索するツールを作ってみる
郵便番号をスプレッドシートの特定のセルに入力すると、住所検索APIを呼び出して、その結果を隣のセルに自動的に入力するスクリプトを作成します。
構成
構成は、domain
、usecase
、port
、gateway
、repository
、client
というよくあるパターンで作成します。
-
domain
:ドメイン(概念)を扱う。スプシやAPIなど外部インターフェイスに一切影響を受けない概念そのもの。 -
usecase
:実際にその関数でやること、業務ロジックを入れる -
port
:依存性逆転のためのインターフェイス -
gateway
:概念と外部インターフェイスとの相互変換を行う(腐敗防止層) -
repository
:スプシは永続化層であるDBと同等の存在として考えて、スプシのやり取りを行う -
client
:外部API(今回は郵便番号)とのやり取りを行う。
個人的に「スプシのデータをDBと同じように扱う」というのがミソかなと思ってます
実際に書いてみる
レポジトリを公開してます
https://github.com/p3033119/gas-clean-architecture
1. 環境構築
claspを使ってTypeScriptでかける環境を用意します
以前に書いた記事があるので参考にしてもらえると
またclaspでpushできるように.clasp.jsonを作成します
{
"scriptId": "<deploy対象のスクリプトID>",
"rootDir": "dist"
}
※スクリプトIDはスプシの拡張機能->App Script->プロジェクトの設定から知ることができます
次に環境変数であるスクリプトプロパティにSPREDSHEET_IDという名前で、スプレッドシートIDを入れます(repositoryで使います)
2.ドメインモデルを書く
次にアプリを動かすうえで必要となる「概念」を定義します。
今回の場合ですと以下の2つを定義します。
- 郵便番号
- 住所
// 郵便番号
export class PostalCode {
constructor(public value: string) {
// 7桁の数字でない場合はエラーを投げる
if (!/^\d{7}$/.test(value)) {
throw new Error(`Value(=${value}) must be a 7-digit number.`);
}
}
}
// 住所
export class Address {
constructor(public value: string) {}
}
3. handler層を書く
handlerとなる一番外側の層のindex.tsを書きます
import { ZipCloudClient } from "./client/zipCloudClient";
import { SpreadSheetGateway } from "./gateway/spreadSheetGateway";
import { ZipCloudGateway } from "./gateway/zipCloudGateway";
import { SpreadSheetRepository } from "./repository/spreadSheetRepository";
import { PostalUsecase } from "./usecase/postalUsecase";
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu("住所検索")
.addItem("郵便番号から住所を取得", "getAddressByPostalCode")
.addToUi();
}
function getAddressByPostalCode() {
try {
// ---- DI(inversify.jsでもワンチャンいけるかも) ----
const spreadSheetRepositoryPort = new SpreadSheetRepository();
const zipCloudClientPort = new ZipCloudClient();
const spreadSheetGatewayPort = new SpreadSheetGateway(
spreadSheetRepositoryPort
);
const zipCloudGatewayPort = new ZipCloudGateway(zipCloudClientPort);
const postalUsecase = new PostalUsecase(
spreadSheetGatewayPort,
zipCloudGatewayPort
);
// ----------------------------------------------
postalUsecase.searchAddressByPostalCode();
} catch (e: unknown) {
if (e instanceof Error) {
SpreadsheetApp.getUi().alert(e.message);
} else {
SpreadsheetApp.getUi().alert("予期せぬエラーが発生しました。");
}
}
}
色々書いてますが実質handlerでやってることはこの1行だけです。
postalUsecase.searchAddressByPostalCode();
この時点でまだ作ってないusecase/gateway/client/repositoryなどの実装は、一旦TODOにしておきましょう(とりあえずindex.tsでエラーが出ない状態にする)
import { ZipCloudGateway } from "../gateway/zipCloudGateway";
import { SpreadSheetGatewayPort } from "../port/gatewayPort/spreadSheetGatewayPort";
import { ZipCloudGatewayPort } from "../port/gatewayPort/zipCloudGatewayPort";
export class PostalUsecase {
constructor(
private readonly spreadSheetGatewayPort: SpreadSheetGatewayPort,
private readonly zipCloudGatewayPort: ZipCloudGatewayPort
) {}
searchAddressByPostalCode = (): void => {
throw new Error("TODO")
};
}
4.業務ロジック(usecase)を書く
次に業務ロジックを書きます。
ここでは「スプシのセルの位置」とか「APIのリクエストのリクエスト/レスポンスの形」とか一切気にする必要ありません。
業務ロジックは「スプシからどうやってセルの値を取得してるか?」とか「APIにどんなリクエスト送ってどんなレスポンスを取ればいいか?」とかそういった外部のことなんて一切知る必要なんてないんです
今回だと以下の感じになるかと思います
- 郵便番号を取得
- 取得した郵便番号をAPIにリクエストして住所を取得
- 取得した住所をセット
import { SpreadSheetGatewayPort } from "../port/gatewayPort/spreadSheetGatewayPort";
import { ZipCloudGatewayPort } from "../port/gatewayPort/zipCloudGatewayPort";
export class PostalUsecase {
constructor(
private readonly spreadSheetGatewayPort: SpreadSheetGatewayPort,
private readonly zipCloudGatewayPort: ZipCloudGatewayPort
) {}
searchAddressByPostalCode = (): void => {
// 1.郵便番号をセルから取得
const postalCode = this.spreadSheetGatewayPort.getPostalCode();
// 2.取得した郵便番号をAPIにリクエストして住所を取得
const address = this.zipCloudGatewayPort.searchPostalCode(postalCode);
// 3.取得した住所をセルにセット
this.spreadSheetGatewayPort.setAddress(address);
};
}
5. 腐敗防止層(gateway)を書く
次にドメインモデルとスプシや外部APIなど、外部の値⇔概念を変換する層を書きます。
ドメインモデル<->スプシの値を変換するgateway
import { Address } from "../domain/address";
import { PostalCode } from "../domain/postalCode";
import { SpreadSheetGatewayPort } from "../port/gatewayPort/spreadSheetGatewayPort";
import { SpreadSheetRepositoryPort } from "../port/repositoryPort/spreadSheetRepositoryPort";
export class SpreadSheetGateway implements SpreadSheetGatewayPort {
constructor(
private readonly spreadSheetRepositoryPort: SpreadSheetRepositoryPort
) {}
getPostalCode = (): PostalCode => {
const postalValue = this.spreadSheetRepositoryPort.getPostalCodeCellValue();
return new PostalCode(postalValue);
};
setAddress = (address: Address): void => {
this.spreadSheetRepositoryPort.setAddressCellValue(address.value);
};
}
ドメインモデル<->外部APIの値を変換するgateway
import { Address } from "../domain/address";
import { PostalCode } from "../domain/postalCode";
import { ZipCloudClientPort } from "../port/clientPort/zipCloudClientPort";
import { ZipCloudGatewayPort } from "../port/gatewayPort/zipCloudGatewayPort";
export class ZipCloudGateway implements ZipCloudGatewayPort {
constructor(private readonly zipCloudClientPort: ZipCloudClientPort) {}
searchPostalCode = (postalCode: PostalCode): Address => {
const response = this.zipCloudClientPort.getAddress(postalCode.value);
/*
ZipCloud公式サイトより
results[0].address1: 都道府県名
results[0].address2: 市区町村名
results[0].address3: 番地以降
なので文字列を結合していれる
*/
return new Address(
`${response.results[0].address1}${response.results[0].address2}${response.results[0].address3}`
);
};
}
6. スプシのセルの操作(repository)を書く
いよいよスプシのセルの値取得・セットの処理を書きます。
ここで具体的に「どこのセルから郵便番号を取るか」「どこのセルに住所をセットするか」という処理を書きます
import { SpreadSheetRepositoryPort } from "../port/repositoryPort/spreadSheetRepositoryPort";
export class SpreadSheetRepository implements SpreadSheetRepositoryPort {
private getSheet = (
sheetName: string
): GoogleAppsScript.Spreadsheet.Sheet => {
const spreadsheetId =
PropertiesService.getScriptProperties().getProperty("SPREADSHEET_ID")!;
// スプレッドシートを開く
const spreadsheet = SpreadsheetApp.openById(spreadsheetId);
// シート名を指定してシートを取得
const sheet = spreadsheet.getSheetByName(sheetName);
if (sheet) {
Logger.log(`シートが見つかりました: ${sheet.getName()}`);
return sheet;
} else {
throw new Error("指定されたシートが見つかりません");
}
};
private SHEET_NAME = "住所取得シート";
// 郵便番号の入ってるセルの値を取得
getPostalCodeCellValue = (): string => {
try {
const sheet = this.getSheet(this.SHEET_NAME);
// 今回はB3セルに郵便番号が入っているのでそれを取得
const data = sheet.getRange("B3").getValue();
return data;
} catch (e: any) {
throw new Error(
`getSheetValues error: シートのデータ取得に失敗しました: ${e.stack}`
);
}
};
// 住所をセルにセット
setAddressCellValue = (address: string): void => {
try {
const sheet = this.getSheet(this.SHEET_NAME);
// 今回はC3セルに住所をセット
sheet.getRange("C3").setValue(address);
} catch (e: any) {
throw new Error(
`setAddressCellValue error: シートのデータセットに失敗しました: ${e.stack}`
);
}
};
}
重要な考え方
この関数はB3セルから郵便番号の値を取得して返り値として返してるわけですが、この関数は返してる文字列が郵便番号であることなんて知りません
この関数はあくまで「ただ文字列を返してるだけ」という感覚です
getPostalCodeCellValue = (): string
下記も同様で、引数名がaddressとなっているものの、このaddressが住所であることをこの関数走りません。
ただの文字列を特定のセルに入れてるだけです
setAddressCellValue = (address: string): void
一番外側の層であるrepositoryは、「郵便番号」も「住所」なんて概念も知らなくて、
「ただただ指定のセルから値を取ったり、指定のセルに値を入れてるだけ」という役割です。
7. 外部API呼び出し(client)を書く
最後にZipCloudのAPI呼び出し部分を書きます
import { ZipCloudClientPort } from "../port/clientPort/zipCloudClientPort";
export class ZipCloudClient implements ZipCloudClientPort {
getAddress = (postalCode: string): ZipCloudSearchResponse => {
const url = `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${postalCode}`;
try {
const response = UrlFetchApp.fetch(url);
const jsonResponse: ZipCloudSearchResponse = JSON.parse(
response.getContentText()
);
if (jsonResponse.status !== 200) {
throw new Error("取得中にエラーが発生しました。");
}
if (!jsonResponse.results) {
throw new Error("正しい郵便番号を入力してください。");
}
return jsonResponse;
} catch (e: unknown) {
if (e instanceof Error) {
throw new Error(`getAddress: 取得中にエラーが発生しました。${e.stack}`);
}
throw new Error(`getAddress: 取得中にエラーが発生しました。${e}`);
}
};
}
export type ZipCloudSearchResponse = {
message: string | null;
results: [
{
address1: string;
address2: string;
address3: string;
kana1: string;
kana2: string;
kana3: string;
prefcode: string;
zipcode: string;
}
];
status: number;
};
この関数も同様に、引数にpostalCodeとはついてはいますが、「ただ受け取った文字列をリクエストにしてAPIを投げるだけ」の役割です
getAddress = (postalCode: string): ZipCloudSearchResponse
これで完成です🎉
なにがいいんこれ?
- なんか層多いしコード量多くね?
- ファイルも多いしめっちゃ複雑な感じがする
- 1個のファイルで書いたほうがシンプルやん
って思いません?自分は思いました
利点1 単体テストが書きやすい
この書き方だと、一個一個をTODOにしていくことで順番に実装していくことができます。
今回ではテストを省略しましたが、異なる層をmockにすることで、順々にかけるので単体テストが非常に書きやすいです。
利点2 修正箇所が明確になる
例えばですが、今はB3セルに郵便番号、C3セルに住所としていますが、もしシートの構成がガラッと変わってしまった場合どうでしょう・・・?
今回ぐらいのやつなら1個のファイルで書いても全然対応できますが、機能がどんどん多くなってきたら、それらを考慮して全て直すのはめちゃくちゃ地獄です(しかも単体テストがなかったら更に地獄・・・)
クリーンアーキテクチャで書いた場合、どれだけスプシが魔改造されても、やりたいことは変わらないのでrepositoryだけいじれば対応できます
さらに一個一個細かく書いてるので、修正量も少なく済みます
利点3 どれだけ規模が大きくなっても修正工数が一定
これは利点1,2でもある通り、テストをしっかり書くことで保守性を担保できます。
また役割がしっかり別れているので、ドメインモデルをしっかり分けていれば、スパゲッティ化することはまずありません。
「コードが少ない=シンプル」ではない
エンジニアなりたての頃は、どうしても「コード量が少ない=良いコード」と感じて、変な共通化や関数化をしてしまいがちですが
真にシンプルなコードは「責務/役割がしっかり分かれているコード」です。
「ファイル・記述量が多い=複雑である」ではありません
さいごに
思ったより長い記事になってしまいました・・
GASで大規模なサービスを作ろうとしてる方がいたら、ぜひ参考にしていただけると幸いです!
リポジトリも公開してるのでよかったら参考にしてください!
https://github.com/p3033119/gas-clean-architecture