React × Serverless Framework でキーワードにマッチするconnpass のイベントを通知するChrome拡張を開発した話

## はじめに

興味のあるトピックについてキーワードを登録しておくと勉強会支援プラットフォームであるconnpassで関連するイベントが開催された際にプッシュ通知を受け取ることのできるChrome拡張をリリースしました！

- [NotiConn](https://chrome.google.com/webstore/detail/noticonn/flejkhlpnnjgnbjpccphbkomfbkhdnbn?hl=ja)

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">🎉 Chrome拡張リリースしました<br><br>気になるトピックを登録しておくとconnpassで関連するイベントが登録された際に通知を受け取れます！<br><br>イベントの先着順に間に合わなかったり、おもしろいイベントを終わってから知ったなんて経験のあるかたはぜひ一度お試しください<a href="https://t.co/E3YHk2oFZX">https://t.co/E3YHk2oFZX</a> <a href="https://t.co/UrGGQl57U5">pic.twitter.com/UrGGQl57U5</a></p>&mdash; こびゃーし (@k0bya4) <a href="https://twitter.com/k0bya4/status/1183757260863889408?ref_src=twsrc%5Etfw">2019年10月14日</a></blockquote>

- [エンジニアをつなぐ IT勉強会支援プラットフォーム connpass](https://connpass.com/)さんの[API](https://connpass.com/about/api/)を使用させていただきました

以下、今回NotiConnを作成した際にやったことについてまとめていきます

## 構成
- connpassのAPIを定期的に実行
- イベントをJSON形式でS3に保存
- イベントのフィルタと取得を行うAPIを用意してChrome拡張から定期的に実行
![s_12530FC0C6FE5E723C939D257CFB7C7E1D83D7DF49C7EA1087A7A61ADFBD0958_1572761125239_noticonn+4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228724/55107f71-45f9-90a9-3917-7564d5218c33.png)

## バックエンドでやったこと

### バックエンドを用意した理由

今回の要件だとクライアント側でconnpassのapiを叩きつつ、前回との差分をlocalStorageで管理すればバックエンドは必要ないのですが、あえて用意した理由として２つあります

### connpassのAPI利用による制限
connpassのAPIリファレンスにこのような注意書きが
`※過度な検索やクローリングに対しては、アクセス制限を施す可能性があります。robots.txt を遵守してください。`
各クライアントが一時間ごとにconnpassへリクエストさせると引っかかってしまう恐れがありました
    
- 地域やキーワード等で通知させるイベントを絞りたかった
この条件に関してはバックエンドが絶対に必要ということはありませんが、定石としてクライアントでやるには重いので用意しました

### アーキテクチャについて
今回はマネタイズができないということで、最安価格で実装する というコンセプトで構築していきました。そのためDBを使わずにS3を使用したり、EC2を利用するのではなくLambdaを使用しました
それぞれの役割は、connpassへ最新イベント100件を取得するcronを用意しつつ、S3に保存している最新のイベントIDよりも新しいイベント群をS3に上書き保存しています
クライアントからのリクエスト時には、含まれる指定キーワード、指定地域に該当するイベントを検索して返しています

### Lambdaでの開発サイクル
- CI・CD環境  
    CircleCIからLambdaにデプロイする環境をざっくりと説明します。
develop pushされるとdev環境へデプロイできるapprovalジョブが、master pushでプロダクション環境へデプロイ出来るapprovalジョブが実行できる流れとなっています

```yml

workflows:
  version: 2
  approval-deploy-with-serverless:
    jobs:
      - setup
      - test:
          requires:
            - setup
      - request-deploy:
          type: approval  # approvalを設定したjob
          filters:
            branches:
              only:
                - master
                - develop
          requires:
            - test
      - deploy-prd:
          filters:
            branches:
              only: master
          requires:
            - request-deploy
      - deploy-dev:
          filters:
            branches:
              only: develop
          requires:
            - request-deploy
```

ここで走っているdeploy-dev deploy-prd jobはcredentialを設定した上で事前にMakefileにまとめられている以下のコードを実行しています

```makefile
.PHONY: deploy-dev
deploy-dev:
        yarn sls deploy

.PHONY: deploy-prd
deploy-prd:
        yarn sls deploy --stage=prd
```
- 環境変数の取り扱い  
Lambdaの場合、環境変数はserverless.yamlをもとに埋め込まれるので、serverless.yamlに使用する環境変数を含めなければいけませんが、直接書いてしまうのはやりたくないので、秘密情報は`./serverless/secret` ディレクトリを作成し、そこにyamlで書いた上でserverless.yamlにそのyamlファイルを読み込んでもらうようにしました

```yml

custom:
  defaultStage: dev
  profiles:
    dev: noticonn-dev
    prd: noticonn
  env:
    dev: ${file(./serverless/env/dev.yml)}
    prd: ${file(./serverless/env/prd.yml)}
  functions:
    save:
      dev: ${file(./serverless/functions/save/dev.yml)}
      prd: ${file(./serverless/functions/save/prd.yml)}
  secret:
    slack: ${file(./serverless/secret/slack.yml)}
    map: ${file(./serverless/secret/map.yml)}
```
ここで環境変数やkeyのファイルの場所を定義して

```yml
provider:
  environment:
    BUCKET: ${self:custom.env.${self:provider.stage}.BUCKET}
    EVENT_FILE: ${self:custom.env.${self:provider.stage}.EVENT_FILE}
    SINCE_ID_FILE: ${self:custom.env.${self:provider.stage}.SINCE_ID_FILE}
    ENV: ${self:custom.env.${self:provider.stage}.ENV}
    HOOKS_URL: ${self:custom.secret.slack.HOOKS_URL}
    MAP_API_KEY: ${self:custom.secret.map.MAP_API_KEY}
```

### CircleCIの設定
- ジョブでワークススペースを共有する  
    ブランチによって, 実行されるジョブを変えるために, workflowを用いていますが, workflowは各ジョブごとにworkspaceを持つように実行されるため, 必要な場合, 毎回bundle installや, npm install を実行する必要があります  
当然, 毎回実行されるのは時間もかかりますし, 今回の構成の場合, dockerのbuildなどは必要ないため, 一度npm installを走らせれば, testの実行からdeployまでジョブを走らせることができます  
そこで試してみたことが [persist_to_workspace](https://circleci.com/docs/ja/2.0/configuration-reference/#persist_to_workspace) です
これは, 後続のジョブに指定したディレクトリのファイルを共有する機能です. この機能を使用して, npm installが完了したディレクトリを共有することで, 一度だけ走るように調整しています

```yml

# ディレクトリを共有する場合
- persist_to_workspace:
  root: . # working_directoryに対する絶対パス
  - . # 共有するパス

# 共有されたディレクトリを使用する場合
- attach_workspace: # workspaceをアタッチする
    at: .
```

 - awsのクレデンシャルを通す

serverlessでprofileで環境を指定してデプロイする場合, .aws/configのデフォルト設定は読み込まれません(そんな設定はないとエラーになる). そのため, 以下のように, profileが明示されたconfigファイルが必要となります

```

[noticonn]
aws_access_key_id = # production環境のアクセスキー
aws_secret_access_key = # production環境のシークレット
[noticonn-dev]
aws_access_key_id = # dev環境のアクセスキー
aws_secret_access_key = # dev環境のシークレット
```
もちろん, CircleCiが自動で設定されることはないので, このファイルを作成するスクリプトを作成して, 走らせます

スクリプト

```sh
mkdir -p ~/.aws
touch ~/.aws/credentials
echo "[$1]\naws_access_key_id = $AWS_ACCESS_KEY_ID\naws_secret_access_key = $AWS_SECRET_ACCESS_KEY" > ~/.aws/credentials
```
ジョブ

```yml
- run:
    name: set-credentials
    command: sh ./serverless/script/credentials.sh noticonn-dev
```
- serverlessで環境変数を扱うために, sercretディレクトリ以下にyamlを作成する  
先の項目で, 秘密情報は`./serverless/secret` ディレクトリ以下に配置すると書きましたが, この秘密情報はCicleCIのEnvironment Variablesを用いて管理しています… が, awsのクレデンシャルと同じく, これもyamlをスクリプトを用いて作成する必要があります(yamlに環境変数を読み込む機能はないため)

スクリプト

```sh
touch ./serverless/secret/map.yml
echo "MAP_API_KEY: $MAP_API_KEY" > ./serverless/secret/map.yml 
```

ジョブ

```yml
- run:
      name: create-config-map
      command: sh ./serverless/script/map.sh
```

serverless framwork + CircleCiでデプロイ環境を作るためのジョブは最終的こんな形になりました

```yml

jobs:
  setup:
    working_directory: ~/workspace
    docker:
      - image: circleci/node:10.12.0
    steps:
      - checkout # ソースコードを作業ディレクトリにチェックアウトする特別なステップ
      - run:
          name: update-npm
          command: 'sudo npm install -g npm@latest'
      - restore_cache: # 依存関係キャッシュを復元する特別なステップ
          # 依存関係キャッシュについては https://circleci.com/docs/ja/2.0/caching/ をお読みください
          key: dependency-cache-{{ checksum "package-lock.json" }}
      - run:
          name: install-npm
          command: npm install
      - run:
          name: install-yarn
          command: npm install yarn
      - save_cache: # 依存関係キャッシュを保存する特別なステップ
          key: dependency-cache-{{ checksum "package-lock.json" }}
          paths:
            - ./node_modules
      - run:
          name: create-config-slack
          command: sh ./serverless/script/slack.sh
      - run:
          name: create-config-map
          command: sh ./serverless/script/map.sh
      - persist_to_workspace:
          root: . # workspaceのrootパス
          paths:
            - . # 共有するパス
  test:
    working_directory: ~/workspace
    docker:
      - image: circleci/node:10.12.0
    steps:
      - attach_workspace: # workspaceをアタッチする
          at: .
      - run:
          name: test
          command: npm run test
  deploy-prd:
    working_directory: ~/workspace
    docker:
      - image: circleci/node:10.12.0
    steps:
      - attach_workspace: # workspaceをアタッチする
          at: .
      - run:
          name: set-credentials
          command: sh ./serverless/script/credentials.sh noticonn
      - run:
          name: deploy-prd
          command: make deploy-prd
  deploy-dev:
    working_directory: ~/workspace
    docker:
      - image: circleci/node:10.12.0
    steps:
      - attach_workspace: # workspaceをアタッチする
          at: .
      - run:
          name: set-credentials
          command: sh ./serverless/script/credentials.sh noticonn-dev
      - run:
          name: deploy-dev
          command: make deploy-dev
```

## フロントエンドでやったこと
拡張機能のフロントはTypeScriptとReactで実装しました

### create-react-appを使用してChrome拡張の開発を始める
create-react-appを使用してChrome拡張の開発を始めるまでの手順は以前書いた記事がありますので参照してみてください

- [create-react-app で TypeScript × React での Chrome Extension 開発を始める] (https://qiita.com/k0bya4/items/e0155bbb6d88c46ba711)

実装について数カ所ピックアップして解説します

### ユーザの入力したトピックをlocalStorageに保存
今回はブラウザ上のアイコンクリックでポップする画面から興味のあるトピックを登録します

<img width="346" alt="スクリーンショット 2019-10-20 20.37.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228724/892b2419-3f47-d282-bf66-0713320efc8f.png">

トピックは

- テキストボックスから登録
- 各トピックの x ボタン押下でトピック削除

可能なものを作成しました

トピックはlocalStorageに保存しました
アプリケーションではオブジェクトとして扱い、保存時にJSON文字列に変換しています

読み込み時

```typescript
const [topics, setTopic] = useState<Topics>(
  JSON.parse(localStorage.getItem(TOPICS_STORAGE_KEY))
);
```

保存時

```typescript
localStorage.setItem(TOPICS_STORAGE_KEY, JSON.stringify(topics));
```

保存/削除のクリックイベントでオブジェクトの更新とlocalStorageへの保存を行いました

### 定期的にイベント取得APIを実行する
イベント一覧は定期的に最新の状態になるのでイベントの取得を行うAPIをそのタイミングに合わせて叩きます

Chrome拡張で定期的に処理を実行させるには alarms APIを使用します

- https://developer.chrome.com/apps/alarms

毎時15分にイベントの取得を行います

```typescript
const when = new Date().setHours(0, 15, 0, 0);
const periodInMinutes = 60;
const alarmName = "fetchEvents";
    
chrome.alarms.create(alarmName, { when, periodInMinutes });
    
chrome.alarms.onAlarm.addListener(alarm => {
  if (alarm.name === alarmName) {
    const topics = Object.keys(JSON.parse(localStorage.getItem(TOPICS_STORAGE_KEY)));
  if (topics.length === 0) {
    return;
  }
    fetchEvents(topics);
  }
});
```

### トピックにマッチしたイベントをChromeのプッシュ通知として表示させる

<img width="344" alt="スクリーンショット 2019-10-12 14.50.05.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228724/803952e7-bfc9-ee59-409a-8ae097441c05.png">

Chrome拡張からの通知には notifications APIを使用します

- https://developer.chrome.com/apps/notifications

今回はイベントのURLを通知のIDとして利用することでクリック時にconnpassのイベントへリンクさせました

プッシュ通知の作成

```typescript
const pushNotification = (event: Event) => {
  const options = {
    iconUrl: "icon128.png",
    type: "basic",
    title: `NotiConn`,
    message: `${event.topic}に関連するイベントが公開されました\n${event.title} by ${event.owner}\n${event.url}`
  };
  chrome.notifications.create(event.url, options);
};
```

通知をクリックした際の挙動はイベントリスナーで定義できます

通知のクリック時の処理

```typescript
chrome.notifications.onClicked.addListener(notificationId => {
  chrome.tabs.create({ url: notificationId });
  chrome.notifications.clear(notificationId);
});
```

## まとめ
以上、初めてのChrome拡張開発でしたがReactとServerless Frameworkをメインにリーズナブルにサービスを実現できました
ぜひ、インストールして使用してみてください！

## とりとめない所感など
### アーキテクチャの反省点
- lambdaの同時実行制限
  - 1000まで
  - 現状のユーザ数では問題ないが同タイミングで全クライアントがリクエストを行う仕様を見直す必要もあるかも
    - https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/limits.html
- Dynamoで良かったかも
    - 保存するデータが大きくなる想定で節約のためにS3をデータストアとして利用したが、この規模感であればDynamoDBでも金額は変わらない
    
### chrome拡張の仕様
- Chrome拡張の次期バージョンをストアに公開しても即時反映はされず同期的にクライアントをバージョンアップさせることができない
  - APIとクライアントの実装が同期している必要があれば考慮が必要
  - https://developer.chrome.com/apps/autoupdate
- tabsが要らなかった話
  - 新規にタブを開くのにはtabs権限はいらない
        - https://qiita.com/tksugimoto/items/0e9ada7efc3b6570e10c#%E9%96%8B%E7%99%BA%E8%80%85%E5%90%91%E3%81%91%E7%96%91%E3%82%8F%E3%82%8C%E3%81%AA%E3%81%84%E3%81%9F%E3%82%81%E3%81%AB
  - tabs権限を要求してしまうとインストール時 `閲覧履歴の読み取り` が表示されるので心象よくないかも
    
    <img width="458" alt="スクリーンショット 2019-10-21 20.42.23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228724/9bcb087d-f2b2-2d4a-360b-6d77cecb6460.png">

### 現在のユーザ数
50人到達しました 🎉
![image (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228724/7e8945d5-7858-c827-dd65-4b28da7f9e97.png)

### 運用してみてのAWSの料金
![image (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228724/47d34ea1-2efb-e41d-dff8-bfdd49734867.png)

## メンバー
以下のメンバーで開発・執筆しました

- こびゃーし
 - https://twitter.com/k0bya4
 - https://qiita.com/k0bya4
- さかす
 - https://twitter.com/sakas1231
 - https://qiita.com/sakas1231
- tm_paseri
 - https://twitter.com/tm_paseri
 - https://qiita.com/tm_paseri