2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

ゴミの日を忘れがちなのでLINE Notifyでゴミの日を自分に通知するプログラムを作成します!

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

要件定義

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

技術選定

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

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

LINE Notify

LINE NotifyにLINEアカウントでログインしてください。
マイページでトークンを発行します。
通知は自分宛でもグループ宛でも設定が可能です。

SelectLINEroom.png

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

開発

開発環境

開発環境はこちらの記事で紹介しているテンプレートを使って構築しています。
モダンな開発ができればどのような構成でもよいですが、ご参考までに。

プロパティ設定

プロジェクトを開いたオンラインエディタの画面でプロパティ設定ダイアログを開きます。
SetProjectProperty.png

今回はスクリプト内でDateを扱うのでタイムゾーンを日本時間に設定します。
SetProjectProperty2.png

続いてスクリプトのプロパティを開きます。
SetProjectProperty3.png

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

コーディング

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

Date

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

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を宣言しました。

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

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

interfaces/ICredentialService.ts
export interface ICredentialService {
  getCredential: (key: string) => string | null;
}

keyに対応したクレデンシャルをstringで返却するメソッドを持ちます。見つからない場合はnullを返します。

そしてこの実装。

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になったとしても、

export class CredentialSpreadSheetService implements ICredentialService {
  getCredential = (key: string): string | null => {
    // GoogleSpreadSheetからトークンとか取得する処理
  }
}

このようなクラスを作って利用箇所を差し替えればすぐに対応できます!すごい!

ログを残すサービス

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

interfaces/ILoggerService.ts
export interface ILoggerService {
  log: (log: string) => void;
  info: (log: string) => void;
  error: (log: string) => void;
  warn: (log: string) => void;
}

全てlogメッセージを受け取り、どこかにログを残してくれます。そしてその実装。

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コンソールで確認することができます。

構成の目的

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

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

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

通知を送信するサービス

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

interfaces/INotifyService.ts
export interface INotifyService {
  sendNotification: (message: string) => boolean,
}

messageを受け取って通知を送信します。戻り値のbooleanは通知送信が成功したかどうかです。
そしてこのインターフェイスを実装したもの。

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のドキュメントを参照してください。

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

構成の目的

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

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

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に差し替えるのです!

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

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

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はゴミの種類を渡すと「燃えるゴミの日です。」みたいなメッセージを返してくれます。
実装したものはこれ。

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];
  };
}
services/GarbageDay.config.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,
}

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

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

構成の目的

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

export class SpreadSheetGarbageDayService implements IGarbageDayService {
  constructor() { }

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

  getMessage = (type: GarbageType): string => {
    /* SpreadSheetからメッセージを探す処理 */
  };
}

そしてGarbageDayServiceのインスタンスの代わりにSpreadSheetGarbageDayServiceのインスタンスに差し替えるのです!

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

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

interfaces/INotifyGarbageDayService.ts
export interface INotifyGarbageDayService {
  notifyGarbageDay: (date: Date) => void;
}

日付を受け取って、その日付がゴミの日の場合通知を送信する処理を定義しています。
実装はこんな感じ。

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);
  }
}

コンストラクタでINotifyServiceIGarbageDayServiceを受け取ります。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があり、デコレータを付与するだけで自動でDIContainerにクラスを登録したり依存関係を解決してくれます。
しかし残念ながらGASで利用することができなかったのでインスタンス生成を担うDIContainerの代わりのクラスを用意しました。

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に通知先を変更する場合は

get NotifyService(): Interfaces.INotifyService {
    return new Services.SlackNotifyService( // クラス名変更
      this.CredentialService,
      this.LoggerService,
    );
  }

とするだけでよくて、INotifyServiceの機能を利用したい側(NotifyGarbageDayService)は何も修正する必要はありません。受け取る実装がLineNotifyServiceからSlackNotifyServiceに変更されたことを知る必要もありません。

処理の起点

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

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

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

トリガーの設定

Webエディタの画面で時計マークをクリックします。
setTrigger.png

「+ トリガーを追加」ボタンからダイアログを表示させます。
setTrigger2.png

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

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

を選択して保存します。

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

終わりに

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

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?