【GAS+TypeScript】LINENotifyでゴミの日を通知するスクリプトを作る過程で、全力でクラス設計を考えてみた

ゴミの日を忘れがちなので[LINE Notify](https://notify-bot.line.me/ja/)でゴミの日を自分に通知するプログラムを作成します！

(実は半年前にPythonで同じものを作ってHerokuで動かしてるけどその後GASとTypeScriptの知見を得たので作り直しますｗ)

## 要件定義

- 毎日21:00にプログラムが実行される
- 次の日がゴミの日だった場合、メッセージを送信する
- どんなゴミ(可燃ゴミ、資源ゴミ、etc...)が捨てられるかをメッセージで知らせる
- 次の日がゴミの日ではない場合、メッセージを送信しない
- クラウド上で無料で動かす(ここ重要)

## 技術選定

- GoogleAppsScript
- TypeScript
- Visual Studio Code
- Node.js

完全無料のJavaScript実行環境のGoogleAppsScript！Googleのサービスとも簡単に連携できて外部にHTTPも送信できて定期実行もスケジュールできる！今回の要件にもってこいです。
型がないと生きていけない身体なのでTypeScriptを使います。
VSCodeは普段から使ってるから。なんでもいいです。
Node.jsはjs系の開発に欠かせないので。

## LINE Notify

[LINE Notify](https://notify-bot.line.me/ja/)にLINEアカウントでログインしてください。
[マイページ](https://notify-bot.line.me/my/)でトークンを発行します。
通知は自分宛でもグループ宛でも設定が可能です。

![SelectLINEroom.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299625/29f4b1bb-2a28-116b-d97b-65b0e54170a9.png)

トークンはこの段階でどこかしらにメモしておかないと二度と表示できないので要注意です。

## 開発

### 開発環境

開発環境は[こちらの記事](https://qiita.com/stin_dev/items/5de879438623b652bb5f)で紹介しているテンプレートを使って構築しています。
モダンな開発ができればどのような構成でもよいですが、ご参考までに。

### プロパティ設定

プロジェクトを開いたオンラインエディタの画面でプロパティ設定ダイアログを開きます。
![SetProjectProperty.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299625/ca413ec8-2196-357f-a4a2-9c6154203728.png)

今回はスクリプト内で`Date`を扱うのでタイムゾーンを日本時間に設定します。
![SetProjectProperty2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299625/c78908ab-d2e9-f7a7-37be-6f26ba6da4e5.png)

続いてスクリプトのプロパティを開きます。
![SetProjectProperty3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299625/285f7548-5ae4-8719-20a5-351134122af5.png)

ここにLINENotifyのトークンを入力しておきます。そうするとソースコードから参照することができ、トークンをハードコーディングすることを回避できます。


### コーディング

機能としてはゴミの日をLINEに通知するという簡単なものですが、タイトルにもある通り今回はオーバーキル気味に超丁寧なクラス設計を考えたいと思います！
`import`,`export`等は記事上では記述しませんが良しなにやってると思ってください。

#### `Date`

要件にあるように次の日を知る必要があります。また、ゴミの日は「第3水曜日」のように何番目の何曜日かを知る必要があります。これらは`Date`の情報のみに依存するので`date.tomorrow`みたいな感じで取得したい。
なのでDate.prototypeにメソッドを生やします。JavaScriptだと`prototype`に生やすだけでいいですがTypeScriptは型定義をしないとコンパイルエラーになるので`declare global{}`内に型定義を書くことでコンパイラに型を知らせます。自動補完も効きます。

```ts:extenstions/DateExtenstion.ts
declare global {
  interface Date {
    getNthDayOfWeek: () => { nth: number, day: DayOfWeek };
    tomorrow: () => Date;
    yesterday: () => Date;
  }
}

Date.prototype.getNthDayOfWeek = function () {
  return {
    nth: Math.floor((this.getDate() - 1) / 7) + 1,
    day: this.getDay(),
  }
}

Date.prototype.tomorrow = function () {
  const tomorrow = new Date(this.getTime());
  tomorrow.setDate(this.getDate() + 1);
  return tomorrow;
}

Date.prototype.yesterday = function () {
  const yesterday = new Date(this.getTime());
  yesterday.setDate(this.getDate() - 1);
  return yesterday;
}

export enum DayOfWeek {
  Sunday = 0,
  Monday = 1,
  Tuesday = 2,
  Wednesday = 3,
  Thursday = 4,
  Friday = 5,
  Saturday = 6,
}
```
`yesterday`はただのおまけです(笑)
`getNthDayOfWeek`の曜日のほうは`number`でもいいですが、コードの可読性向上と補完を効かせるために`enum`を宣言しました。

#### クレデンシャルを返却するサービス

どこかに保存されたクレデンシャルを取得して利用側に返却する機能を提供します。

```ts:interfaces/ICredentialService.ts
export interface ICredentialService {
  getCredential: (key: string) => string | null;
}
```
`key`に対応したクレデンシャルを`string`で返却するメソッドを持ちます。見つからない場合は`null`を返します。

そしてこの実装。

```ts:services/CredentialPropertyService.ts
export class CredentialPropertyService implements ICredentialService {
  private props = PropertiesService.getScriptProperties();

  constructor() { }

  getCredential = (key: string): string | null => {
    const token = this.props.getProperty(key);

    return token;
  }
}
```
`PropertiesService`はGoogleAppsScriptが用意しているサービスで、[プロパティ設定](#プロパティ設定)で入力したプロパティを取得することができます。

##### 構成の目的

`ICredentialService`を挟むことで、クレデンシャルを利用する側はどこからどのように取得されてるのかを気にする必要はなく、`getCredential`を呼べば欲しい値が取得できます！

もし仕様変更でクレデンシャルを保存する場所がプロパティから例えばGoogleSpreadSheetになったとしても、

```ts
export class CredentialSpreadSheetService implements ICredentialService {
  getCredential = (key: string): string | null => {
    // GoogleSpreadSheetからトークンとか取得する処理
  }
}
```
このようなクラスを作って利用箇所を差し替えればすぐに対応できます！すごい！

#### ログを残すサービス

4段階のログをどこかに残す機能を提供します。

```ts:interfaces/ILoggerService.ts
export interface ILoggerService {
  log: (log: string) => void;
  info: (log: string) => void;
  error: (log: string) => void;
  warn: (log: string) => void;
}
```
全て`log`メッセージを受け取り、どこかにログを残してくれます。そしてその実装。

```ts:services/ConsoleLoggerService.ts
export class ConsoleLoggerService implements ILoggerService {
  constructor() { }

  log = (log: string) => {
    console.log(log);
  }

  info = (log: string) => {
    console.info(log);
  }

  error = (log: string) => {
    console.error(log);
  }

  warn = (log: string) => {
    console.warn(log);
  }
}
```

やってることはすべて受け取った引数を`console`に渡しているだけ。GoogleAppsScriptの`console`で書き出せるログは[Webコンソール](https://script.google.com/home/executions)で確認することができます。

##### 構成の目的

ログを残したい側は、コンソールだろうがテキストファイルだろうがDBだろうがログをどこに残すかはどうでもよいはず。
`ILoggerService`はそのような内部実装を隠蔽してくれます！
もしコンソールではなくGoogleSpreadSheetにログを残すという仕様に変更になったら...？

```ts
export class SpreadSheetLoggerService implements ILoggerService {
  log = (log: string) => { /*SpreadSheetに書き出す処理*/ }
  info = (log: string) => { /*SpreadSheetに書き出す処理*/ }
  error = (log: string) => { /*SpreadSheetに書き出す処理*/ }
  warn = (log: string) => { /*SpreadSheetに書き出す処理*/ }
}
```

こんなクラスを作って差し替えてやればいいんですねぇ！簡単！

#### 通知を送信するサービス

通知を何かしらのチャットサービスに送信する機能を提供します。

```ts:interfaces/INotifyService.ts
export interface INotifyService {
  sendNotification: (message: string) => boolean,
}
```
`message`を受け取って通知を送信します。戻り値の`boolean`は通知送信が成功したかどうかです。
そしてこのインターフェイスを実装したもの。

```ts:services/LineNotifyService.ts
export class LineNotifyService implements INotifyService {
  private lineNotifyUrl = `https://notify-api.line.me/api`;

  constructor(
    private credentialService: ICredentialService,
    private loggerService: ILoggerService,
  ) { }

  sendNotification = (message: string): boolean => {
    const lineToken = this.credentialService.getCredential("LINE_NOTIFY_TOKEN");
    if (!lineToken) {
      this.loggerService.warn("LINE_NOTIFY_TOKEN not found.");
      return false;
    }

    const response = UrlFetchApp.fetch(`${this.lineNotifyUrl}/notify`,
      {
        method: "post",
        headers: {
          Authorization: `Bearer ${lineToken}`
        },
        payload: {
          message: message,
        },
        muteHttpExceptions: true,
      });

    switch (response.getResponseCode()) {
      case 200:
        this.loggerService.info(`A notification have been posted successfully.`);
        return true;

      case 400:
        this.loggerService.error(`Invalid request.`);
        break;

      case 401:
        this.loggerService.error(`Invalid token.`);
        break;

      case 500:
        this.loggerService.error(`LINE Notify server error.`);
        break;

      default:
        this.loggerService.error(`Unknown error.`);
        break;
    }

    return false;
  };
}
```

この記事のタイトル通り、LINENotifyのAPIを叩いて通知を送信します。APIの詳細に関しては[LINENotifyのドキュメント](https://notify-bot.line.me/doc/ja/)を参照してください。

このクラス、LINENotifyのトークンを欲しがってますねぇ！トークンを返却してくれるサービスがあったはず...。そう、`ICredentialService`ですね！
コンストラクタで`ICredentialService`を実装したインスタンスを受け取り、処理中で利用します。決して`CredentialPropertyService`を受け取るようにはしません！クラス内部で`new CredentialPropertyService()`なんて言語道断です！
なぜなら`LineNotifyService`にとってトークンがプロパティに隠されてるなんて関係なく、受け取れさえすればいいのですから。
さらにこのクラスはログも残したいみたい...。`ILoggerService`の出番ですね！`ConsoleLoggerService`に依存してはいけません！`LineNotifyService`はログがSpreadSheetに吐かれていようがコンソールに残されていようが関係ないのです！

##### 構成の目的

今回は最初からLINEに通知するのが目的でしたが、たとえば明日LINEがサービス停止になったら...？~~そんなわけない~~
大変です！通知先の変更をしなければなりません。じゃあSlackに通知しますか？Discordに通知しますか？
でも通知を送信したい方からすれば送信先はなんでもよいはず。`INotifyService`は具体的な送信先を隠蔽します。

本当にLINEのサービスが停止してしまった場合は次のようなクラスを作りましょう！

```ts
export class SlackNotifyService implements INotifyService {
  private slackApiUrl = `https://slack.com/api`; // 適当です

  constructor(
    private credentialService: ICredentialService,
    private loggerService: ILoggerService,
  ) { }

  sendNotification = (message: string): boolean => {
    /* credentialServiceからSLACK_API_TOKENをキーにトークンを取得して
       slackにhttpリクエストを送信する処理。
       適宜loggerServiceでログも取得する。 */
  };
}
```
そして`LineNotifyService`から`SlackNotifyService`に差し替えるのです！

#### どんなゴミの日か判定するサービス

どんなゴミの日かを判定します。

```ts:interfaces/IGarbageDayService.ts
export interface IGarbageDayService {
  getGarbageType: (nth: number, dayOfWeek: DayOfWeek) => GarbageType | undefined;
  getMessage: (type: GarbageType) => string;
}

export type GarbageType = "burnable" | "incombustible" | "plastic" | "recyclable";
```

`getGarbageType`は第何何曜日かを伝えると出せるゴミの種類を返してくれます。ゴミの日でないならば`undefined`になります。
`getMessage`はゴミの種類を渡すと「燃えるゴミの日です。」みたいなメッセージを返してくれます。
実装したものはこれ。

```ts:services/GarbageDayService.ts
import { garbageDayConfig, garbageDayMessage } from "./GarbageDay.config";

export class GarbageDayService implements IGarbageDayService {
  constructor() { }

  getGarbageType = (nth: number, dayOfWeek: DayOfWeek): GarbageType | undefined => {
    const config = garbageDayConfig[DayOfWeek[dayOfWeek]].find(c => c.nth === nth);

    if (config) {
      return config.garbagetype;
    } else {
      return;
    }
  };

  getMessage = (type: GarbageType): string => {
    return garbageDayMessage[type];
  };
}
```

<details><summary>services/GarbageDay.config.ts(無駄に長いので折り畳み)</summary><div>

```ts:services/GarbageDay.config.ts
interface GarbageDayConfig {
  [dayOfWeek: string]: {
    nth: number;
    garbagetype: GarbageType;
  }[];
};

interface GarbageMessage {
  burnable: string;
  incombustible: string;
  plastic: string;
  recyclable: string;
}

export const garbageDayConfig: GarbageDayConfig = {
  Sunday: [],
  Monday: [
    {
      nth: 1,
      garbagetype: "plastic"
    },
    {
      nth: 2,
      garbagetype: "incombustible"
    },
    {
      nth: 3,
      garbagetype: "plastic"
    }
  ],
  Tuesday: [
    {
      nth: 1,
      garbagetype: "burnable"
    },
    {
      nth: 2,
      garbagetype: "burnable"
    },
    {
      nth: 3,
      garbagetype: "burnable"
    },
    {
      nth: 4,
      garbagetype: "burnable"
    },
    {
      nth: 5,
      garbagetype: "burnable"
    }
  ],
  Wednesday: [
    {
      nth: 4,
      garbagetype: "recyclable"
    }
  ],
  Thursday: [],
  Friday: [
    {
      nth: 1,
      garbagetype: "burnable"
    },
    {
      nth: 2,
      garbagetype: "burnable"
    },
    {
      nth: 3,
      garbagetype: "burnable"
    },
    {
      nth: 4,
      garbagetype: "burnable"
    },
    {
      nth: 5,
      garbagetype: "burnable"
    }
  ],
  Saturday: []
}

const burnableMessage = `燃えるゴミの日です`;

const incombustibleMessage = `燃えないゴミ日です。`;

const plasticMessage = `プラスチック製容器包装の日です。`;

const recyclableMessage = `資源ごみの日です。`;

export const garbageDayMessage: GarbageMessage = {
  burnable: burnableMessage,
  incombustible: incombustibleMessage,
  plastic: plasticMessage,
  recyclable: recyclableMessage,
}

```
</div></details>

ゴミの日の情報や返却するメッセージは`GarbageDay.config.ts`に持っています。~~SpreadSheetから引っ張るようにすればよかったと後悔しています。~~

`getGarbageType`も`getMessage`も`GarbageDay.config.ts`から受け取ったオブジェクトに対して引数で検索を掛ける実装になっています。

##### 構成の目的

やっぱりSpreadSheetにゴミの日の情報を持ってよという変更依頼が来ました！やっぱりね！最初からそうしといたほうがよいと思ってたよ。
SpreadSheetに記入されたゴミの日情報を取得するクラスを作りましょう！

```ts
export class SpreadSheetGarbageDayService implements IGarbageDayService {
  constructor() { }

  getGarbageType = (nth: number, dayOfWeek: DayOfWeek): GarbageType | undefined => {
    /* SpreadSheetからゴミの種類を探す処理 */
  };

  getMessage = (type: GarbageType): string => {
    /* SpreadSheetからメッセージを探す処理 */
  };
}
```
そして`GarbageDayService`のインスタンスの代わりに`SpreadSheetGarbageDayService`のインスタンスに差し替えるのです！

#### ゴミの日を通知するサービス

今回のメインロジックを担うサービスとなります。

```ts:interfaces/INotifyGarbageDayService.ts
export interface INotifyGarbageDayService {
  notifyGarbageDay: (date: Date) => void;
}
```
日付を受け取って、その日付がゴミの日の場合通知を送信する処理を定義しています。
実装はこんな感じ。

```ts:services/NotifyGarbageDayService.ts
export class NotifyGarbageDayService implements INotifyGarbageDayService {
  constructor(
    private notifyService: INotifyService,
    private garbageDayService: IGarbageDayService,
  ) { }

  notifyGarbageDay = (date: Date) => {
    const nthDayOfWeek = date.getNthDayOfWeek();
    const garbageType = this.garbageDayService.getGarbageType(nthDayOfWeek.nth, nthDayOfWeek.day);

    if (!garbageType) { return; }

    const message = this.garbageDayService.getMessage(garbageType);

    this.notifyService.sendNotification(message);
  }
}
```
コンストラクタで[`INotifyService`](#通知を送信するサービス)と[`IGarbageDayService`](#どんなゴミの日か判定するサービス)を受け取ります。`LineNotifyService`とか`GarbageDayService`に依存してはいけません！通知先をLINEからSlackに変更する際にこのクラスの修正はしたくありませんからね！

`notifyGarbageDay`メソッドでは`date.getNthDayOfWeek`で第何何曜日かを確認して`IGarbageDayService`にゴミの日かどうかを問い合わせます。
ゴミの日ではない場合はプログラムを終了します。
ゴミの日の場合は`IGarbageDayService`からメッセージを取得して`INotifyService`に通知送信を依頼します。

##### 構成の目的

このクラスに関しては要件を満たすメインロジックのため差し替えの可能性を考慮する必要はなさそうです。
しかし、今回の要件は「毎日21:00に実行されて、次の日がゴミの日だった場合、メッセージを送信する」ですが、「やっぱり毎朝実行してその日がゴミの日かどうかを知りたいわ」となった場合、このクラス内部で`date.tomorrow()`としているとこのクラスを修正する必要があります。
なので日付を外部から受け取ってその日付がゴミの日かどうかを判断して通知を送信する構成にしました。

#### DIContainer

今までさんざん依存してはいけませんとか利用箇所を差し替えるとか言ってましたが、どのように実現するのでしょうか？
そもそもコンストラクタから受け取るって、誰がコンストラクタに渡してくれるの？渡すインスタンスは誰が生成してるの？

その役割を担うのがDIContainerです。DIとはDependency Injectionの略称で「依存性の注入」と訳されるものです。
今回作成したServiceクラスが他のServiceを利用する際、内部で`new HogeService()`とするのではなく、必ずコンストラクタで利用したい機能の抽象(interface)を受け取る形にしてきました。このように依存している機能を外部から受け取るパターンのことをDIといいます。

そしてDIパターンを実現するためのツールとしてDIContainerがあります。
DIContainerはインスタンスの生成を担っているクラスです。
DIContainerはフレームワークに標準で搭載されていたり、パッケージとして配布されていたりします。TypeScriptにもマイクロソフト製の[TSyringe](https://github.com/microsoft/tsyringe)があり、デコレータを付与するだけで自動でDIContainerにクラスを登録したり依存関係を解決してくれます。
しかし残念ながらGASで利用することができなかったのでインスタンス生成を担うDIContainerの代わりのクラスを用意しました。

```ts:containers/DIContainer.ts
import * as Services from "../services";
import * as Interfaces from "../interfaces";

class DIContainer {
  get CredentialService(): Interfaces.ICredentialService {
    return new Services.CredentialPropertyService();
  }

  get LoggerService(): Interfaces.ILoggerService {
    return new Services.ConsoleLoggerService();
  }

  get NotifyService(): Interfaces.INotifyService {
    return new Services.LineNotifyService(
      this.CredentialService,
      this.LoggerService,
    );
  }

  get GarbageDayService(): Interfaces.IGarbageDayService {
    return new Services.GarbageDayService();
  }

  get NotifyGarbageDayService(): Interfaces.INotifyGarbageDayService {
    return new Services.NotifyGarbageDayService(
      this.NotifyService,
      this.GarbageDayService,
    );
  }
}

export const container = new DIContainer();
```
ホンモノ(？)のDIContainerは動的に生成したり条件によってインスタンスを切り替えたりできるのですが、これは静的に生成するためだけのクラスになりました。

例えばLINEからSlackに通知先を変更する場合は

```ts
get NotifyService(): Interfaces.INotifyService {
    return new Services.SlackNotifyService( // クラス名変更
      this.CredentialService,
      this.LoggerService,
    );
  }
```
とするだけでよくて、`INotifyService`の機能を利用したい側(`NotifyGarbageDayService`)は何も修正する必要はありません。受け取る実装が`LineNotifyService`から`SlackNotifyService`に変更されたことを知る必要もありません。

#### 処理の起点

最後にプログラム実行の起点を書きます。

```ts:src/index.ts
global.notifyGarbageDay = () => {
  const service = container.NotifyGarbageDayService;
  service.notifyGarbageDay((new Date()).tomorrow());
}
```

DIContainerから`INotifyGarbageDayService`のインスタンスを受け取り明日の日付を渡します。
これで明日がゴミの日ならばゴミの種類に合わせたメッセージがLINEに届くようになります。

### トリガーの設定

Webエディタの画面で時計マークをクリックします。
![setTrigger.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299625/cc7c935a-8c76-aa0a-0a04-3bf318cdf7f7.png)

「+ トリガーを追加」ボタンからダイアログを表示させます。
![setTrigger2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299625/bb940f42-c071-fd89-9849-ef39543d74ed.png)

実行する関数を正しく選択して

- 時間ベースのトリガーのタイプを選択 = 日付ベースのタイマー
- 時刻を選択 = 午後9時～10時

を選択して保存します。

21:00ジャストに実行できないことを忘れていました。ｗ
でもこれで毎日21:00過ぎに実行されて、次の日がゴミの日ならLINEに通知が届くようになります。めでたしめでたし。

## 終わりに

これくらいの処理なら一枚のスクリプトでいいです。疲れるだけなので。