8
6

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 3 years have passed since last update.

GoogleAppsScript + LINEBot で気圧情報を教えてくれるWebサービスを作りたい

Posted at

はじめに

漠然と「Webサービスを何か作ってみたいなー」思い、色々と調べてみたところ、
GoogleAppsScript(GAS) + LINEBot の組み合わせでなにか作れそうだぞ、という考えに至りました。
提供するサービスの中身については、自分の中で以前より構想していたものの中からピックアップしました。

GAS + LINEBot を選んだ理由としては以下が挙げられます。

・環境構築が不要で、Google Driveをベースに実装していくので端末にとらわれない
・GASがJavaScriptをベースにしているので、学習コストが低い
・GASで指定プログラムを定期実行できる(WindowsのタスクスケジューラやLinuxのcronのイメージ)
・作成したサービスをLINEのプラットフォームで簡単に公開できる

本記事では、作成したLINEBotの機能と実装コードについてご紹介します。

実現したいこと

実装にあたり、今回のLINEBotで実現したいことをざっくりと決めました。

・リプライ機能
 →ユーザーからメッセージを受け取ったら、今後の気圧予報を返す。

・プッシュ機能
 →毎日決まった時間に今後の気圧予報をユーザーに通知する。

・ユーザー管理機能
 →スプレッドシートにてユーザー情報を管理する。管理する情報は以下。
  ・ユーザーID
  ・気圧情報の取得対象地点情報(緯度、経度、住所)
  ・プッシュ機能情報(通知する/しない、通知時間)

つくったもの

完成品はこちら。

気圧ぼっと

L.png

メニュー

F0A0C46B-317C-4CD7-B15D-489A7EF8CE11.jpeg

地点登録

気圧情報を知りたい地点の位置情報を送信すると、緯度、経度、住所が登録されます。
2AD3C866-02A7-4BCC-9CC4-B5A661375967.jpeg
12ACE4A2-3468-4DF4E394900-3E97-4248-A25C-BFE2C841C53E.jpeg 7-980D-8EBB576CA6CF.jpeg

通知ON/OFF

毎日決まった時間に気圧予報を通知する/しないを登録します。
50CB64CA-38EF-4948-8F24-F12C31F65172.jpeg

通知時間変更

通知時間を登録します。
745737AE-2204-49A6-B08E-97891784FA84.jpeg

気圧予報

登録した地点の今後の気圧予報を返信します。
F59927EA-1FB2-4034-9493-5026452E2C1A.jpeg
79EBD04F-48FF-40C6-8991-7A353752379F.jpeg

使用したサービス

・GoogleAppScript
・LINE Developers
FLEX MESSAGE SIMULATOR
・OpenWeatherMap

実装コード

長くなるので折り畳みで表示しています。

ReplyMessage.gs:リプライメッセージ関連
ReplyMessage.gs
/**
 * リプライメッセージ関連
 */

/**
 * LINEメッセージを受信した際に呼び出されるメソッド
 * @param e イベントオブジェクト
 */
function doPost(e) {
  let recvFromUsrJson = JSON.parse(e.postData.contents).events[0];
  // WebHookで受信した応答用トークン
  let replyToken = recvFromUsrJson.replyToken;
  // ユーザーから送信された内容により処理を振り分け、送信アイテムを作成する
  let sendItem = createSendItem(recvFromUsrJson);
  // リプライメッセージ送信
  sendReplyMessage(replyToken, sendItem);
}


/**
 * ユーザーから送信された内容により処理を振り分け、送信アイテムを作成する
 * @param recvFromUsrJson ユーザーから受信したJSON
 * @return 送信アイテム
 */
function createSendItem(recvFromUsrJson) {
  let spreadSheetController = new SpreadSheetController();
  let sendItemFormatter = new SendItemFormatter();
  let sendItem = [];

  // ユーザーデータを取得する
  let userData = spreadSheetController.getUserRow(recvFromUsrJson.source.userId);
  // 「地点登録」ボタンの場合
  if (recvFromUsrJson.message.type === 'location') {
    sendItem.push(registLocation(spreadSheetController, sendItemFormatter, recvFromUsrJson, userData));

  } else if (userData[1].length === 0) {
    // それ以外で新規ユーザーの場合、先に地点登録をしてもらう
    sendItem.push(sendItemFormatter.createTextSendItem('先に地点を登録してね。'));

  } else if (recvFromUsrJson.message.type === 'text') {
    // 既存ユーザーの場合、メッセージによって処理を振り分ける
    let message = recvFromUsrJson.message.text;
    if (message === TEXT_FORECAST) {  // 「気圧予報」ボタン
      let latitude = userData[1][2];
      let longitude = userData[1][3];
      let openWeatherMapGateway = new OpenWeatherMapGateway(latitude, longitude);
      let bubbleList = openWeatherMapGateway.createForecastData();
      sendItem.push(sendItemFormatter.createTextSendItem('これからの気圧だよ。'));
      sendItem.push(sendItemFormatter.createFlexSendItem(bubbleList, 'これからの気圧だよ'));

    } else if (message === TEXT_NOTICE_FLG) {
      // 「通知ON/OFF」ボタン
      sendItem.push(sendItemFormatter.createButtonSendItem(TEXT_NOTICE_FLG, '選択肢を選んでね', OPTION_NOTICE_FLG));

    } else if (OPTION_NOTICE_FLG.includes(message)) {
      // 「通知ON/OFF」の選択肢
      sendItem.push(registNoticeFlg(spreadSheetController, sendItemFormatter, message, userData));

    } else if (message === TEXT_NOTICE_TIME) {
      // 「通知時間変更」ボタン
      sendItem.push(sendItemFormatter.createButtonSendItem(TEXT_NOTICE_TIME, '選択肢を選んでね', OPTION_TIME_LIST));

    } else if (OPTION_TIME_LIST.includes(message)) {
      // 「通知時間変更」の選択肢
      sendItem.push(registNoticeTime(spreadSheetController, sendItemFormatter, message, userData));

    } else {
      // どれにも当てはまらなった場合
      sendItem.push(sendItemFormatter.createTextSendItem('メニューから選んでね。'));
    }
  }

  return sendItem;
}


/**
 * 地点変更
 * @param spreadSheetController スプレッドシート操作クラスのインスタンス
 * @param sendItemFormatter 送信アイテム加工クラスのインスタンス
 * @param recvFromUsrJson ユーザーから受信したメッセージJson
 * @param userData スプレッドシートから取得してきたユーザーデータ
 * @return 送信テキスト
 */
function registLocation(spreadSheetController, sendItemFormatter, recvFromUsrJson, userData) {
  // スプレッドシートに書き込み
  spreadSheetController.registLocation(recvFromUsrJson, userData);
  // 送信アイテム返却
  return sendItemFormatter.createTextSendItem('地点を登録しました。');
}


/**
 * 通知ON/OFF変更
 * @param spreadSheetController スプレッドシート操作クラスのインスタンス
 * @param sendItemFormatter 送信アイテム加工クラスのインスタンス
 * @param message ユーザーから受信したメッセージ
 * @param userData スプレッドシートから取得してきたユーザーデータ
 * @return 送信テキスト
 */
function registNoticeFlg(spreadSheetController, sendItemFormatter, message, userData) {
  // スプレッドシートに書き込み、送信アイテム返却
  if (message === OPTION_NOTICE_FLG[0]) {
    spreadSheetController.registNoticeFlg(1, userData);
    return sendItemFormatter.createTextSendItem('通知をONに設定しました。');
  } else {
    spreadSheetController.registNoticeFlg(0, userData);
    return sendItemFormatter.createTextSendItem('通知をOFFに設定しました。');
  }
}


/**
 * 通知時間変更
 * @param spreadSheetController スプレッドシート操作クラスのインスタンス
 * @param sendItemFormatter 送信アイテム加工クラスのインスタンス
 * @param message ユーザーから受信したメッセージ
 * @param userData スプレッドシートから取得してきたユーザーデータ
 * @return 送信テキスト
 */
function registNoticeTime(spreadSheetController, sendItemFormatter, message, userData) {
  // スプレッドシートに書き込み
  spreadSheetController.registNoticeTime(message, userData);
  // 送信アイテム
  return sendItemFormatter.createTextSendItem(message + 'に設定しました。');
}


/**
 * リプライメッセージ送信
 * @param replyToken 応答用トークン
 * @param sendItem 送信アイテム
 */
function sendReplyMessage(replyToken, sendItem) {
  UrlFetchApp.fetch(URL_REPLY, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + LINE_TOKEN,
    },
    'method': 'post',
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': sendItem,
    }),
  });
}
PushMessage.gs:プッシュメッセージ関連
PushMessage.gs
/**
 * プッシュメッセージ関連
 */

/**
 * AM7時~8時のトリガーにて実行するメソッド
 */
function execAM8() {
  exec(OPTION_TIME_LIST[0]);
}


/**
 * PM19時~20時のトリガーにて実行するメソッド
 */
function execPM20() {
  exec(OPTION_TIME_LIST[1]);
}


/**
 * PM23時~24時のトリガーにて実行するメソッド
 */
function execPM24() {
  exec(OPTION_TIME_LIST[2]);
}


/**
 * トリガーにて実行する実処理
 * @param execTime 当該処理の実行時間 */
function exec(execTime) {
  let spreadSheetController = new SpreadSheetController();
  let sendItemFormatter = new SendItemFormatter();

  // ユーザーデータを取得する
  let users = spreadSheetController.getAllUser();
  users.forEach(user => {
    // 通知フラグが 1:通知ON かつ 通知時間と当該処理の実行時間が一致しているユーザーを処理対象とする
    let noticeFlg = user[4];
    let noticeTime = user[5];
    if (noticeFlg == 1 && noticeTime == execTime) {
      // 緯度・経度をもとに天気予報データを取得・加工
      let openWeatherMapGateway = new OpenWeatherMapGateway(user[2], user[3]);
      let bubbleList = openWeatherMapGateway.createForecastData();
      let sendItem = [];
      sendItem.push(sendItemFormatter.createFlexSendItem(bubbleList, 'これからの気圧予報です'));
      sendPushMessage(user[0], sendItem)
    }
  });
}


/**
 * プッシュメッセージ送信
 * @param toUserId 送信先ユーザーID
 * @param sendItem 送信アイテム
 */
function sendPushMessage(toUserId, sendItem) {
  UrlFetchApp.fetch(URL_PUSH, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + LINE_TOKEN,
    },
    'method': 'post',
    'payload': JSON.stringify({
      "to": toUserId,
      'messages': sendItem,
    }),
  });
}
SpreadSheetController.gs:スプレッドシート関連
SpreadSheetController.gs
/**
 * スプレッドシート操作クラス
 */
class SpreadSheetController {

  /**
   * コンストラクタ
   */
  constructor() {
    this.ss = SpreadsheetApp.getActiveSpreadsheet();   // スプレッドシート
    this.sheet_user = this.ss.getSheetByName('user');  // userシート
  }


  /**
   * 全ユーザーを取得する
   * @return 全ユーザーデータ
   */
  getAllUser() {
    // 受け取ったシートのデータを二次元配列に取得
    let users = this.sheet_user.getDataRange().getValues();
    // ヘッダー行は削除
    users.shift();
    return users;
  }


  /**
   * ユーザーIDでシートを検索して行番号と行データを取得する
   * @param userId 取得対象のユーザーID
   * @return 行番号と行データ(新規ユーザーの場合は最終行+1の行番号と空のリスト)
   */
  getUserRow(userId) {
    // 受け取ったシートのデータを二次元配列に取得
    let dat = this.sheet_user.getDataRange().getValues();
    for (let i = 1; i < dat.length; i++) {
      if (dat[i][0] === userId) {
        return [i + 1, dat[i]];
      }
    }
    // 存在しない場合(新規ユーザー)
    return [this.sheet_user.getLastRow() + 1, []];
  }


  /**
   * 地点登録
   * @param recvFromUsrJson ユーザーから受信したメッセージJSON
   * @param userData スプレッドシートから取得してきたユーザーデータ
   */
  registLocation(recvFromUsrJson, userData) {
    // スプレッドシートに書き込み
    this.sheet_user.getRange(userData[0], COLNUM_USERID).setValue(recvFromUsrJson.source.userId);
    this.sheet_user.getRange(userData[0], COLNUM_ADDRESS).setValue(recvFromUsrJson.message.address);
    this.sheet_user.getRange(userData[0], COLNUM_LATITUDE).setValue(recvFromUsrJson.message.latitude);
    this.sheet_user.getRange(userData[0], COLNUM_LONGITUDE).setValue(recvFromUsrJson.message.longitude);
    if (userData[1].length === 0) {
      // 新規ユーザーの場合
      this.sheet_user.getRange(userData[0], COLNUM_NOTICE_FLG).setValue('1');
      this.sheet_user.getRange(userData[0], COLNUM_NOTICE_TIME).setValue('24時');
    }
    this.sheet_user.getRange(userData[0], COLNUM_LAST_UPD_DT).setValue(Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss'));
  }


  /**
   * 通知ON/OFF変更
   * @param NoticeFlg 通知フラグ 0:通知OFF 1:通知ON
   * @param userData スプレッドシートから取得してきたユーザーデータ
   */
  registNoticeFlg(noticeFlg, userData) {
    this.sheet_user.getRange(userData[0], COLNUM_NOTICE_FLG).setValue(noticeFlg);
    this.sheet_user.getRange(userData[0], COLNUM_LAST_UPD_DT).setValue(Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss'));
  }


  /**
   * 通知時間変更
   * @param noticeTime 通知時間
   * @param userData スプレッドシートから取得してきたユーザーデータ
   */
  registNoticeTime(noticeTime, userData) {
    this.sheet_user.getRange(userData[0], COLNUM_NOTICE_TIME).setValue(noticeTime);
    this.sheet_user.getRange(userData[0], COLNUM_LAST_UPD_DT).setValue(Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss'));
  }
}
OpenWeatherMapGateway.gs:OpenWeatherMap関連
OpenWeatherMapGateway.gs
/**
 * OpenWeatherMapAPIクラス
 */
class OpenWeatherMapGateway {

  /**
   * コンストラクタ
   * @param latitude 取得地点の緯度
   * @param longitude 取得地点の経度
   */
  constructor(latitude, longitude) {
    this.url_now = Utilities.formatString(URL_NOW, latitude, longitude, WEATHER_TOKEN);
    this.url_forecast = Utilities.formatString(URL_FORECAST, latitude, longitude, WEATHER_TOKEN);
  }


  /**
     * 天気予報データを取得・加工する
     * @return 加工後の天気予報データ
     */
  createForecastData() {
    let forecastData = [];
    let json = this.getWeatherData('2');
    for (let i = 0; i <= 5; i++) {
      let text = this.createForecastText(json, i);
      // 今日の天気情報が存在しない場合はスキップ
      if (i === 0 && text.body == '') {
        continue;
      }
      // 明日以降で天気情報が存在しなくなったら処理を抜ける
      if (i !== 0 && text.body == '') {
        break;
      }
      forecastData.push(text);
    }
    return forecastData;
  }


  /**
   * APIにアクセスして天気データを取得する
   * @param getDataKbn 取得するデータの区分 1:現在のデータ 2:予報データ
   * @return JSON形式の天気データ
   */
  getWeatherData(getDataKbn) {
    if (getDataKbn === '1') {
      let response = UrlFetchApp.fetch(this.url_now);
      let forecastResponse = JSON.parse(response);
      return forecastResponse;
    };
    let response = UrlFetchApp.fetch(this.url_forecast);
    let forecastResponse = JSON.parse(response);
    // UNIXTIMEを所定の日付形式に変換する
    forecastResponse.list.forEach(row => {
      let rowDate = new Date(row['dt'] * 1000);
      row['dt_txt'] = Utilities.formatDate(rowDate, TIMEZONE, TIME_FORMAT);
    });
    return forecastResponse;
  }


  /**
   * i日後の天気データを加工する
   * @param json JSON形式の天気データ
   * @param i i日後
   * @return {header: 日付, body: 天気データのテキスト}
   */
  createForecastText(json, i) {
    let weatherText = "";
    // 日付、データフォーマット曜日作成
    let dateAndFormatAndYoubi = this.makeDateAndFormatAndYoubi(i);
    let targetDay = dateAndFormatAndYoubi[1].slice(0, 11);

    let currentPressure = 0; // 今回気圧
    let lastPressure = 0;    // 前回気圧
    let pressureDiff = 0;    // 気圧差
    // 気圧情報から警告をセットする
    json.list.forEach(row => {
      // i日後のデータのみ計算
      if (row.dt_txt.slice(0, 11) == targetDay) {
        // 今回気圧をセット
        currentPressure = row.main.pressure;
        // 天気の絵文字をセット
        let iconcode = row.weather[0].icon.slice(0, 2);
        let emoji = this.getWeatherIcon(iconcode);
        if (lastPressure !== 0) {
          weatherText += NEW_LINE
          // 前回気圧と今回気圧の気圧差を計算
          pressureDiff = currentPressure - lastPressure;
        }
        // 気温を小数点1位に丸める
        let temp = parseFloat(row.main.temp).toFixed(1);
        // 返信メッセージの作成
        weatherText += row.dt_txt.slice(12, 14) + "時 " + emoji + " " + temp + "" + row.main.pressure + "hPa";
        // 気圧差が+-3の場合は警告アイコンを追加
        if (pressureDiff <= -3 || 3 <= pressureDiff) {
          weatherText += " 🥺";
        }
      }
      // 前回気圧をセット
      lastPressure = currentPressure;
    });
    return { header: targetDay + dateAndFormatAndYoubi[2], body: weatherText };
  };


  /**
   * 天気のアイコンを取得する
   * @param 天気コード
   * @return 天気アイコン
   */
  getWeatherIcon(iconCode) {
    if (iconCode == "01" || iconCode == "02") {
      return ""; // 晴れ
    }
    else if (iconCode == "03" || iconCode == "04") {
      return ""; // 曇り
    }
    else if (iconCode == "09" || iconCode == "10") {
      return ""; // 雨
    }
    else if (iconCode == "11") {
      return "🌩"; // 雷
    }
    else if (iconCode == "13") {
      return ""; // 雪
    }
    else if (iconCode == "50") {
      return "🌫"; // 霧
    }
    else {
      return ""
    }
  };


  /**
   * i日後のDateオブジェクトと日付フォーマットと曜日を作成する
   * @param i i日後
   * @return [Dateオブジェクト, 日付フォーマット、曜日]のリスト
   */
  makeDateAndFormatAndYoubi(i) {
    let sysDate = new Date();
    let resultDate = new Date(sysDate.getFullYear(), sysDate.getMonth(), sysDate.getDate() + i)
    let resultFormat = Utilities.formatDate(resultDate, TIMEZONE, TIME_FORMAT);
    let youbiList = ['', '', '', '', '', '', '']
    let youbi = "" + youbiList[resultDate.getDay()] + "";
    return [resultDate, resultFormat, youbi];
  }
}
SendItemFormatter.gs:送信アイテム関連
SendItemFormatter.gs
/**
 * 送信アイテム加工クラス
 */
class SendItemFormatter {

  /**
   * テキスト形式の送信アイテムを作成する
   * @param text 送信テキスト
   * @return 送信アイテム
   */
  createTextSendItem(text) {
    return { 'type': 'text', 'text': text }
  }


  /**
   * ボタン形式の送信アイテムを作成する
   * @param mainText メッセージに表示するメインテキスト
   * @param subText メッセージに表示するサブテキスト
   * @param actionList 選択肢を格納したリスト
   * @return 送信アイテム
   */
  createButtonSendItem(mainText, subText, actionList) {
    let actions = [];
    actionList.forEach(action => {
      actions.push({
        'type': 'message',
        'label': action,
        'text': action
      });
    });
    return {
      "type": "template",
      "altText": mainText,
      "template": {
        "type": "buttons",
        "title": mainText,
        "text": subText,
        "actions": actions
      }
    }
  }


  /**
   * フレックス形式の送信アイテムを作成する
   * @param items [{header:ヘッダー, body:中身}]形式のリスト
   * @param altText 注釈テキスト
   * @return 送信アイテム
   */
  createFlexSendItem(items, altText) {
    let contents = [];
    items.forEach(item => {
      contents.push({
        "type": "bubble",
        "size": "kilo",
        "header": {
          "type": "box",
          "layout": "vertical",
          "contents": [
            {
              "type": "text",
              "text": item.header,
              "color": "#ffffff",
              "align": "center",
              "size": "md",
              "gravity": "center"
            }
          ],
          "backgroundColor": "#27ACB2",
          "paddingTop": "19px",
          "paddingAll": "12px",
          "paddingBottom": "16px"
        },
        "body": {
          "type": "box",
          "layout": "vertical",
          "contents": [
            {
              "type": "box",
              "layout": "horizontal",
              "contents": [
                {
                  "type": "text",
                  "text": item.body,
                  "color": "#8C8C8C",
                  "size": "sm",
                  "wrap": true
                }
              ],
              "flex": 1
            }
          ],
          "spacing": "md",
          "paddingAll": "12px"
        },
        "styles": {
          "footer": {
            "separator": false
          }
        }
      });
    });
    return {
      'type': 'flex',
      'altText': altText,
      'contents': {
        "type": "carousel",
        "contents": contents
      }
    }
  }
}
Constants.gs:定数定義
Constants.gs
/**
 * 定数定義
 */
// OpenWeatherMapAPI用アクセストークン
const WEATHER_TOKEN = {penWeatherMapAPI用アクセストークン};
// 現在の天気データを取得するURL
const URL_NOW = 'http://api.openweathermap.org/data/2.5/weather?lat=%s&lon=%s&APPID=%s&units=metric';
// 未来の天気予報を取得するURL
const URL_FORECAST = 'http://api.openweathermap.org/data/2.5/forecast?lat=%s&lon=%s&APPID=%s&units=metric';
// タイムゾーン
const TIMEZONE = 'Asia/Tokyo'
// 表示用日時フォーマット
const TIME_FORMAT = 'yyyy年MM月dd日 HH:mm:ss'
// 改行タグ
const NEW_LINE = "\n";

// スプレッドシート関連
const COLNUM_USERID = 1;       // カラム「ユーザーID」の列番号
const COLNUM_ADDRESS = 2;      // カラム「住所」の列番号
const COLNUM_LATITUDE = 3;     // カラム「緯度」の列番号
const COLNUM_LONGITUDE = 4;    // カラム「経度」の列番号
const COLNUM_NOTICE_FLG = 5;   // カラム「通知フラグ」の列番号
const COLNUM_NOTICE_TIME = 6;  // カラム「通知フラグ」の列番号
const COLNUM_LAST_UPD_DT = 7;  // カラム「最終更新日時」の列番号

// LINEBotのメッセージ送受信用アクセストークン
const LINE_TOKEN = {LINEBotのメッセージ送受信用アクセストークン};
// リプライメッセージ用のAPI URL
const URL_REPLY = 'https://api.line.me/v2/bot/message/reply';
// プッシュメッセージ用のAPI URL
const URL_PUSH = 'https://api.line.me/v2/bot/message/push';

// 送信メッセージ用テキスト
const TEXT_FORECAST = '気圧予報';
const TEXT_NOTICE_FLG = '通知ON/OFF';
const OPTION_NOTICE_FLG = ['通知をONにする', '通知をOFFにする'];
const TEXT_NOTICE_TIME = '通知時間変更';
const OPTION_TIME_LIST = ['7時~8時', '19時~20時', '23時~24時'];

お世話になった記事・リンク

GASで気圧の変化をお知らせしてくれるLINEbotをつくる
  GASの設定やOpenWeatherMap関連の実装はこちらの記事を大いに参考にさせていただきました。

LINE Messaging API 公式ドキュメント
 →Messaging APIの実装で困ったときはこれを見ていました。

さいごに

GASのエディターを初めて使ってみましたが、予測変換をはじめコードのフォーマットやデバッグ、定義/参照への移動など、機能が充実していて想像以上に快適にコーディングを進めることができました。

また、LINE Messaging API側の実装は思うようにいかないことが多かったです。
動作確認も、プログラム修正 → LINEBotに登録 → 実機でLINE操作 という流れで行っており、時間がかかるうえにどこで処理がこけているかも把握しづらかったです。
(ここは工夫次第でもっと効率よく開発できたと思います。)

また何かネタがまとまったら作ってみたいと思います。

8
6
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
8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?