Twitterでお菓子と一緒にメッセージを送れるサービスを二週間で開発、リリースした【個人開発】

こんにちは、Qiitaに初めて投稿する[G4RDS](https://twitter.com/G4RDS)と申します。

一年半前からWebを触り始めて、最近はVue.js/Nuxt.jsを使ってFirebaseをバックにWebサービスを作るのを趣味にしています。
今年度が大学一年生です。

今回、メッセージをお菓子のイラストと一緒にTwitterのリプライやDMで贈れる「chocotto」というサービスをリリースしました。
これが僕が作ったWebサービス二作目です。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">サービス開始しました！<br><br>chocottoは無料で使えるギフトメッセージサービスです💌<br>メッセージをチョコなどのお菓子のイラストと一緒に贈れます🍪<br><br>もうすぐバレンタイン🍫<br>遠くてチョコを渡せない人にも、chocottoで想いを届けてみませんか？<a href="https://twitter.com/hashtag/chocotto?src=hash&amp;ref_src=twsrc%5Etfw">#chocotto</a><a href="https://t.co/J3ONNEpKwu">https://t.co/J3ONNEpKwu</a></p>&mdash; chocotto (@chocotto_co) <a href="https://twitter.com/chocotto_co/status/1094902035319648256?ref_src=twsrc%5Etfw">2019年2月11日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

この記事ではこのchocottoのサービスと技術の紹介と、開発中に直面した問題について解説したいと思います。

# chocottoとは
[chocotto](https://chocotto.co)は、TwitterのリプライやDMの機能を使って、お菓子のイラストと一緒にメッセージを送れるサービスです。
ギフトメッセージを作成すると、サーバーがOGP画像を生成してツイートもしくはDMを送信します。
![ogp-image.png](https://qiita-image-store.s3.amazonaws.com/0/322168/26777fc7-62c7-8578-2c36-babbeefc3fdc.png)

お菓子は、チョコ、クッキー、キャンディ、マカロンの四種類から選べ、それぞれ十数種類ほど絵柄のバラエティが用意されています。
![gifts.png](https://qiita-image-store.s3.amazonaws.com/0/322168/08602ac6-5017-e5f4-cede-53fc339ab293.png)

日時を指定してツイートやDMを予約送信できます。
中学生がバレンタインデーにチョコレートを贈る時間は、朝学校についたとき、昼休み、放課後のどれかだなーと思って、ショートカットを用意しておきました。
放課後に通知が来て「何かな～？」と確認する好きな人を眺めるときに使ってください。
![datetime-shortcuts.png](https://qiita-image-store.s3.amazonaws.com/0/322168/adf980fb-3e1c-bd7d-28ac-93bb445fb757.png)

誰が誰に何のギフトを贈ったか、まではOGP画像で出力されてしまいますが、メッセージ内容だけはプライべートに設定できます。
プライベートに設定されたメッセージは、宛先に指定されたユーザーでログインしないと表示されません。

[公式アカウント](https://twitter.com/chocotto_co)で[僕のアカウント](https://twitter.com/G4RDSjp)にメッセージを送ってみました。
全員に公開になっているので、下のリンクから見れます。
https://chocotto.co/messages/o1SNXSmYdo6SDNiHqVpk

誰でも気軽にお菓子を贈れるサービスです。ぜひ使ってみてください！
https://chocotto.co

# chocottoで使った技術
chocottoではどういった技術を使っているのか、フロントエンドとバックエンドの両方について紹介します。

## フロントエンド
chocottoのフロントエンドは[Nuxt.js](https://ja.nuxtjs.org)で構築されています。Nuxt.jsは、かの有名なJSフレームワークVue.jsを簡単にSSRできるようにした拡張フレームワークです。最近ロゴが刷新されてかっこよくなりましたね。
Vue.jsのようなSPAだと、遷移するときに変わる部分だけを書き変えることでパフォーマンスも通信量も良くできます。
しかし、OGPのようなmetaデータをTwitterのクローラーに読み込ませたいようなとき、クライアントで後から挿入するSPAはクローラーがJSを実行しないために正しく解釈されないので使うのが難しいです。
SSRするNuxt.jsなら、クライアントにデータが届いた時点で既にmeta情報が入っているので、Twitterのクローラーも正しく認識できます。

chocottoではOGPがかなり重要なのでNuxt.jsを採用しました。他にもVue.jsよりも使いやすい面が多々あって、ほんと神。

### Tailwind CSS
![tailwind-css.png](https://qiita-image-store.s3.amazonaws.com/0/322168/ca34fc03-a975-c3e7-0b99-c6c15ebba990.png)
chocottoではCSSフレームワークの[Tailwind CSS](https://tailwindcss.com)を使っています。
TailwindはHTMLのクラスにmt-4とかflex items-centerとか指定するだけでCSSを簡単に適用できるフレームワークで、BootstrapやVuetifyのようなカスタムコンポーネントはありません。

CSSは\<style\>\</style\>に全部書くべきだ！という意見もわかるのですが、繰り返さない場合やそこまで大きい部分ではないという場合にいちいちstyleタグまで見に行くのは面倒です。HTMLに直接書くことで、どんなCSSだったかを直接確認できるので非常に便利です。
ただ、適用するクラスの量が多くなってきたらクラスに分けないと、結果的に可読性が落ちるので要注意。

また、Tailwindはブレークポイントや色、文字サイズや横幅縦幅などの基準が決まっているので、全体のバランスを保てます。つまり、同じようなレベルのpタグに1.1remと1.2remをそれぞれ適用してしまってなんかバランス悪いということがなくなります。
もちろん、レスポンシブ対応が楽になるというのもあるのですが、こういったサイズを分けてくれることでバランス良く見えるというのがすごく大きなメリットだと感じます。

ちなみにCSSはSassのSCSS記法で書いています。SCSSならCSSとほぼ同じ構文で入れ子にできるのですごく良いです。

## バックエンド
chocottoではFirebaseを使用しています。FirebaseはGoogleが提供しているmBaaS(mobile Backend as a Service)と呼ばれるものです。FirebaseにはDBや認証、ストレージなどがあるのですが、うち次のものを使っています。

- Firestore - NoSQLなドキュメントデータベース。JSONのような形式でそのままぶち込めるのですごく使い勝手がいい。ルールも指定したら勝手に処理してくれるので楽。
- Firebase Authentication - 認証機構。Twitterと連携して関数呼び出すだけで勝手にリダイレクトからログイン、ローカル保存まで全部やってくれます。
- Firebase Cloud Functions - AWS Lambdaみたいなイベント駆動のサーバーレスなやつ。Node.jsで関数作ると、簡単にサーバーで処理できます。
- Firebase Cloud Storage - AWS S3みたいなクラウドストレージ。

Firestoreは先月末にβを抜け出しGAに昇格しました。そして、Tokyoリージョンにもやってきたのでネックだったものが全部取っ払われました。
そのため、まだ開発途中だったのでプロジェクトをTokyoリージョンに引越しました。リージョンの変更はできないので、リリース前で良かったです。

Nuxt.jsのホスティングはCloud Functionsでやるのもなしではないのですが、コールドスタートがかなり痛いのでGoogle Cloud PlatformのGoogle App Engineを使っています。
どちらもスケーラブルなのですが、GAEは少なくとも一台は常に稼働させておくということができるので、一定時間アクセスがない時でもすぐにページを返せます。
Nuxt.jsのGAEホスティングは[DMMさんのブログ記事](https://inside.dmm.com/entry/2018/11/06/nuxt2-pwa-gae-se)がすごく参考になりますので、これに沿ってやるのがおすすめです。

### OGP画像の生成
OGP画像はクライアント側とサーバー側のどちらで生成するのかというのはかなり重要です。
クライアント側で生成する場合、もちろんそれをサーバーもしくはクラウドストレージに保存しないといけないので、悪意のあるユーザーが自分の好きな画像をOGP画像に設定できてしまいます。これはちょっと良くないですね。
サーバー側で生成する場合、画像生成という重い処理がかさんでしまいます。

chocottoではサーバー側で生成する方法を選びました。悪用されたくないというのが非常に大きな理由です。
もちろん、一度サーバーの処理を挟むことで正しい画像かどうかなど判断できるのですが、面倒だったのでサーバーで作っちゃいました。

OGPの生成は、予約投稿の場合でもメッセージ作成時に行います。
[node-canvas](https://github.com/Automattic/node-canvas)というnpmパッケージを使えば、サーバーサイドでcanvasを生成しそこでレンダリング、画像出力までできます。
今回はCloud Functionsで使うので、ソースのビルドが既に済んでいる[node-canvas-prebuilt](https://github.com/node-gfx/node-canvas-prebuilt)というパッケージを使いました。

OGP画像の生成はhtml2canvasというHTMLとCSSをcanvasにレンダリングするパッケージを使うのが人気ですが、今回はサーバーサイドでDOMがないため使えません。
そのため、事前にIllustratorでひな形を作っておき、それをバックグラウンドに、動的な部分だけを淡々と描画していくことにしました。
慣れないIllustratorにてまどいながらなんとか作りました。。。

# 開発中に直面した問題たち
このサービスは1/30に思いついてバレンタインまでに間に合わせようと必死に作ったので、発案からリリースまで13日間で作ることができました。
しかし、間に挟んだ平日全てテスト期間で、ヒイヒイ言いながら開発する羽目になりました。
なんとかちゃんと動くところまで持ってきて、リリースできてよかったです。

さて、その開発中に悩みまくって何時間も費やした問題たちについて紹介したいと思います。

## 再読み込みするとasyncDataで認証情報がとってこれない問題
Firebase Authenticationは、ログイン情報をデフォルトでローカルストレージに保存しておくので、明示的にログアウトしない限りはセッションが切れてもログインが継続します。
しかし、なぜかログアウトしちゃうので不便でかなり困りました。

これは単純なミスで、ログイン情報をVuexに格納しておいて、Vuexに入っていたらログインしていると判定していたために、再読み込みしてVuexがリセットされるとログアウトしていると判断されるっていうだけでした。
解決方法として、[vuex-persistedstate](https://github.com/robinvdvleuten/vuex-persistedstate)パッケージを使ってあげればOKです。勝手にローカルストレージから復活してくれます。

ただ、ローカルストレージから復活させるので、再読み込みしたページのasyncDataではサーバーなのでまだvuexが復活していません。（ローカルストレージにはサーバーからアクセスできません。）
chocottoでいうと、Twitterからプライベートメッセージのページに飛んだときに、vuexが復活してヘッダーに自分のアイコンが表示されているのに、ログインが必要ですと表示されてしまう、という問題が発生します。
そのため、asyncDataでvuexを利用しなければならなく、そこがランディングページになるような場合、一度リダイレクトページを挟む必要があります。

Twitterから遷移
→ 「/redirect?r=/path/to」へ遷移
→ mountedフックでroute.query.rに指定されたpathへredirectedクエリを付けて遷移
→ redirectedがあるのでvuexが復活したとみなし、処理開始

というような流れにすることで、完全に解決できます。

## asyncDataでFirestoreドキュメントを取得するとエラーが発生してしまう問題
Firestoreからドキュメントを取得するとオブジェクトが返ってきます。これをasyncDataで取得してdataの中にぶち込むといったことをやるとき、注意しなければならないことがあります。

それは、ドキュメントの中に、Referenceが入っていると「Circular structure to JSON」というようなエラーが発生してしまうことです。
Circular structureというのはその名の通り循環オブジェクトのことで、[MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value)では次のように紹介されています。

> 次のような循環構造体で、

```javascript
var a = {};
var b = {}; 
a.child = b;
b.child = a;
```

> JSON.stringify() は失敗します。

Nuxt.jsがサーバーからクライアントに転送するデータをJSONに変換する際に、Referenceオブジェクトは循環しているために、JSON.stringifyが失敗してしまうようです。

これを解決するには、Referenceをただのpath文字列に置き換えます。

## Firestoreのクエリで取得するとき、forEachだと並列にならない問題
Firestoreでクエリを使って複数のドキュメントを取得するときdocRef.get()はPromiseを返すので、サブコレクションのドキュメントを取得したい場合、async/await関数をforEachに渡すと思います。
しかし、実際にはこれは並列処理になっておらず、forEachの内部でawaitしてしまっています。

これを解決するには、[Array.map](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map)を使います。これを使えば、docs配列の中身に対して全て処理を行った配列を生成できます。
ただ、async/awaitを使うと、Array.mapで返ってくる配列の中身は全部PromiseなのでPromise.allする必要があります。
async関数は速攻でPromiseを返してしまうので、Array.mapはそれをマップしてしまうということです。
以下のようなコードになります。

```javascript
this.results = await Promise.all(querySnap.docs.map(async (messageSnap) => {
  let data = messageSnap.data()

  data.sub = (await messageSnap.ref.collection('sub').doc('doc').get()).data()

  return data
}))
```

しかし、マップ後の配列にこのドキュメントは入れたくないというような場合もあります。
この場合はとりあえずreturn nullしておいて、あとでArray.forEachでいらないドキュメントをArray.spliceすることで、待機時間の長い処理を並列にしながら分けられると思います。

## npm twitter、DM送れない問題
この問題には結構悩まされました。
node.js環境でTwitterAPIを使う場合、このtwitterパッケージが人気なのですが、どうにもDMが送れません。

DM APIの仕様変更で使えなくなったのか、Issueが結構あるのですが修正されていないようです。
["Could not authenticate you." Direct Message Twitter](https://github.com/desmondmorris/node-twitter/issues/315)
ここで代わりに提示されているのがtwitter-liteというパッケージでした。
このパッケージを使うことで解決します。

## Typekit日本語フォント、webfontloaderで読み込めない問題
日本語フォントを含むTypekit（今はAdobe Fonts）のWebキットは、ページに含まれる文字だけをダウンロードして通信料を減らすダイナミックセットというものに自動で変更されます。
Nuxt.jsでTypekitやGoogle Fontsを使う場合はnuxt-webfontloaderというものがあるのですが、ダイナミックセットは読み込めないようで全くロードされません。

index.htmlのひな型はどこにも見当たらないので、nuxt.config.jsのheadプロパティでスクリプトを指定するしかなさそうですが、うまくいかなかったので諦めてGoogle Fontsだけ使用することにしました。
どうにかできないものでしょうか。。

# まとめ
初めてQiitaで宣伝するサービスを作ったので緊張しています。
特に、Peingさんのアクセストークン事件もありましたので、かなり気を使ったつもりです。

バレンタインデーのことを意識してつくりましたが、バレンタインデー以外でも使えると思います。
ぜひ使ってみてください！

最後に、僕のTwitterもフォローしていただけると幸いです。
https://twitter.com/G4RDS

# おまけ
(2/13 7:39追記)
Vue.js/Nuxt.jsで開発をする上で、パフォーマンスの改善やメンテナンスのしやすさの維持などは非常に重要です。
それらに役立つTipsを毎週メールで送ってくれるメールマガジンが最近始まって、結構おすすめです。
[VueDoze](https://vuedose.tips)
過去記事も全て見れます。

全て英語ですが、困った時ggると出てくるのはほとんどReditやGitHubばかりですので充分読めると思います。
普段英語の記事は読まない方も、日常的に英文を読む練習にもなりますからGoogle翻訳を使わずに読むといいと思います。