2020-03-22に更新

敗者のweb1week

読了目安:16分

先週行われた web1week
初開催にも関わらず50を超えるサービスが登場し、大盛況のうちに幕を閉じた。

経験者ならば理解を得られると思うが、個人開発という舞台には魔物が棲んでいる。
着手時点で思い描いた予定などペロリと喰われてしまう。

不意に削られる作業時間。
理想と妥協の狭間で揺れる要件。
技術仕様の落とし穴。
サービスの意義を自問自答し続ける日々。
リリース直後に訪れる無慈悲な障害。

これらを乗り越えローンチされた個人サービスの奥には、語られることのない物語が潜んでいる。
そんなサービスが50以上も並ぶweb1weekのボードは圧巻で、たとえHello Worldの様なサービスでも私の心を踊らせてくれる。
とても甲乙など付けられない。

しかし「敗者」は存在する。
「web1week楽しみ!」と方方で意気込みを語り、「やってみませんか?」と様々な人を勧誘した挙句、あろうことか最小限の機能でさえ1weekに間に合わず、最終的には要件すら満たせなかった。
紛うことなき「敗者」だ。
個人開発界隈ではよく「リリースすることが大事」と言われている。その通りだ。リリースさえすれば勝ちなのだ。

これは、そんな勝負に敗北を喫した男の記録である。

立案

月曜の午前0時、お題「Home」が発表された。
こじつけでも何でも「Home」を絡ませたWebサービスならクリアとのこと。
初学者でも参加し易いうえに、CGMやIoTを絡めたサービスも構想でき、かつ疫病の脅威に怯える時勢に寄り添った素晴らしいお題だと思った。
それと同時に頭を抱えた。
1週間で作り上げるとなると出来る事は限られている。ピンと来るアイディアが何も思い浮かばない。

「自分が実装してみたい機能」というアプローチで考えてみる。
我が家にはGoogle Nest Hubというガジェットが存在する。ディスプレイ付きスマートスピーカーというイロモノだ。
スマート家電のような贅沢品は存在しない我が家だが、昨年の私の誕生日に妻から贈ってもらっていた。しかし、動画を見るにはタブレットやテレビに転送で事足りるし、BGMをかける風習も無い。なかなか使う場面が訪れなかった。
これを何とか活用できないか。

玄関や冷蔵庫に貼ってるホワイトボード。これをNest Hubで代用できないかと考えた。
「いってらっしゃい」「おやつにわらび餅を冷蔵庫に入れてます」の様な、家族間のメッセージは手書きの方が嬉しかったりする。
しかし、ホワイトボードのデメリットはホワイトボードの場所へ行かないと書けないことだ。
仕事中に「冷蔵庫にわらび餅入れてるの忘れてた!」となった場合、LINEに頼るしかない。
スマホから手書きでメッセージを書き、玄関やリビングに置かれたNest Hubに表示されたらエモいのでは。

Nest Hubで表示するWebアプリについて調べてみる。
どうやらInteractive Canvasというフレームワークで実装するらしい。
しかし、基本的にVUI(音声ユーザーインターフェース)が前提となる作りで、ストアへ並べるにはGoogleの承認も必要となる。
今回の要件・納期では厳しい、、

方針を変える。
Nest HubにはGoogle Photosにあるアルバムを選択して、フォトフレームとして表示する機能がある。
Nest Hub側でメッセージ画像を入れたアルバムを選択して、Webサービス側ではそのメッセージ画像を更新すれば良いのはないだろうか。
調べてみた。さすがGoogle様。ちゃんとGoogle PhotosのAPIも存在する。

これでいこう。

  1. WebサービスにGoogleアカウントでログイン
  2. 手書きUIのCanvasでメッセージを書く。
  3. Nest Hub表示用のアルバムをGoogle Photosに作成
  4. Canvasを画像化して3.のアルバムにアップロード
  5. 既にメッセージ画像が存在する場合は削除(アルバム内の画像は1枚のみとする)
  6. Nest Hubで表示

OAuth2認証をクライアントで完結できれば、DBすら不要。
この程度なら1週間でいける。

市場調査もしてみた。

うん、Nest Hub専用サービスは良い感じのスキマ産業のようだ(強がり)

Nuxt.jsでプロジェクトを作成して今日の作業は終了。
この時点で月曜の夜。順調だ。

裏の目的

常々思っていたのが「もっと手軽に画像アップロード機能を作れないか」という事だった。
画像アップロードというのは、ストレージを用意して、アップされる画像の最適化処理を組み、著作権の侵害等を考慮して規約や同意UIの設計をしなければならない。
リリース後は不適切な画像が上がってないかウォッチする義務が発生するし、ストレージの残容量も気にしなければならない。
更に、増え続ける画像の管理方法(一定期間で削除するか、お金で解決するか)も検討しなければならない。
個人開発において、割とコストが高めの機能だ。

この課題はGoogle Photos等のユーザーが保有するクラウドストレージに公開状態で保存すれば解決するのではないかと考えた。
このご時世、Googleアカウントは誰でも持っている。アカウント登録時に自動で付与されるGoogle Photosの容量は高画質モードなら無制限だし、あくまで「画像を公開しているのはユーザー」という体裁を繕えるので著作権問題もグレーゾーンとなる(漫画村方式)
サービス側で保有するのは画像の静的URLのみだ。

今回、Google PhotosのAPIを使いこなせる様になれば今後の個人開発に幅を持たせることが出来るかもしれない。

ハマる

コロナ自粛の影響により勤務時間が削減され、個人開発の時間は比較的多めに取れた。
それでも間に合わなかったのは全てにハマったからだ。
全てだ。
時系列でハマりポイントを紹介していく。

JS ClientSDKが使いづらい

Google APIをWebで利用する場合、Google API Client Library for JavaScript(GAPI)というライブラリを利用する。
Initial Commitは2011年。それなりに年季の入ったライブラリだ。

NPMには登録されていない。

scriptタグで読み込む必要がある。
Nuxt.jsで外部スクリプトを使用する場合、$nextTicks()を用いてもスクリプトのロードが完了していない場合がある。
ロード状態を監視する必要があるので少し手間だ。

DiscoveryDocs

GAPIのスクリプトを読み込んだ時点ではinit()等、最低限の機能しか保有していない。
Getting Startedに記載されているが、API Key等を設定して初期化する際にDiscoveryDocsというURLを設定する。
DiscoveryDocsはAPI単位で用意されており、URLの先にあるJSONをGAPIでロードすれば機能として利用できる。
Google Photos APIのDiscoveryDocsは下記となる。
https://content.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest
スクリプトの肥大化を防ぐための機構だろう。
ここまではまだ良い。手間を感じるがまだ許せる。

同期処理

ドキュメントのサンプルはthenチェーンで記載されている。
じゃあ・・・と思ってasync / awaitで書き直してみる。動かない。
そう、返却値はES6 Promiseじゃない
goog.Thenableという独自インターフェースを継承したオブジェクトだ。
init().then(()=>{ }) の中でしかGAPIは動かない。同期的にいくつものAPIを使いたければthenのネストを深め続けるしかない。F*ck。

ES6が標準化されたのが2015年。このライブラリの開発当初ならスマートな仕様だったのだろう。
しかし、こちとら去年からJSを学び始めた身。
終始、この仕様に慣れず生産性が低下した。

認証で躓く

ユーザー管理にはFirebase Authenticationを採用した。
手軽に導入でき、「特定のユーザーには使わせない」といったセキュリティ対策も出来る。
Twitter API等、他社のAPIと連携する場合はトークンの保管といった一手間が必要になってくるが、今回はGoogle Platform内で連携するだけ。
簡単だろうと思った。

🙅 Firebase → Google API

以下の記事を参考に、Firebase UIでログイン→GAPIのイニシャライズを試みる。
Using Google APIs with Firebase Auth and Firebase UI on the Web

できない。
この記事ではFirebaseでログインした時点で GAPIの認証状態 : gapi.auth2.getAuthInstance().isSignedIn.get()true になる想定だが false だ。
GAPIでもログインする必要があるのか?と思って試したがFirebaseとGAPIで二重ログインされてしまった。

記事のコメント欄に記載がされているが、現在はFirebaseでログインしてもGAPIでログイン状態を検知しない。
下記のissueにもその事が記載されており、ステータスはopenのままだ。
https://github.com/google/google-api-javascript-client/issues/561

🙆 Google API → Firebase

つまり現状、FirebaseとGAPIを連携するには認証順を逆にしないといけない。
GoogleAPIのサインイン : gapi.auth2.signIn()でログインした後、認証情報を取得して Firebaseの認証情報を使用したサインイン : firebase.auth().signInWithCredential()でもログインするフローとなる。
下記の記事を参考に実装した。
How to Use Google APIs on the Web

進捗:だめです

この時点で既に平日は終わっていた。残すところは土日のみ。
対応が長期化した要因は「出来ると書いてあったから」。
Firebase→GAPIの認証は不可能という可能性を考慮していなかった。
この考慮漏れが発生するとプログラマーの思考はどうなるか。
ひたすらタイポを探し続けるのである。

画像アップロードが用意されてない

まだ間に合う。
土曜にアップロード機能を実装。
日曜にデザインを微修正&実機で動作確認。
ここまで盛大に躓いて尚、根拠なく「出来る」と盲信する。
進捗が遅れてるプログラマーにありがちな逆算スケジュールである。

🙅 GAPIで画像アップロード

ガイドラインを読んでみる。
https://developers.google.com/photos/library/guides/upload-media
どうやら画像をアップロードするには、二段階の手順を踏まないといけないようだ。

  1. 画像のバイナリデータをGoogleにアップロードする
  2. 上記返却値に含まれるアップロードトークンを用いてアルバムに追加する。

Google PhotosのDiscoveryDocsから該当のメソッドを探してみる。
無い。
何度検索しても「uploads」という処理は無い。
「なぜ無いのか」なんて考える余裕も無い。
(現在、不安になって再度探してみたがやっぱり無い)

GAPIじゃGoogleに画像をアップロード出来ない・・・?

🙅 axiosでリクエスト

悩んでいる暇は無い。もうクライアントSDKは捨てる。
ドキュメントにはエンドポイントが記載されているので、そこに対してaxiosでPOSTリクエストを投げてみる。

Network Error.

・・・久し振りに見たぜ。
has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
CORSエラーだ。
おいおい、嘘だろ。CORSだと?
浅学だが、基本的にはAPI側での対応が必須だったと記憶している。詰んだか?

🙅 XMLHttpRequestでリクエスト

ドキュメントには「CORSもサポートしているよ」と書かれている。
How to use CORS to access Google APIs
クライアントSDKを用いれば回避できるよ。と。
使えねぇんだよチクショウが。
記事の最後にXMLHttpRequestを用いた例も記載されている。
存在は知っていたが初めて使うなこれ。
愚直に書いてある通り組んでみる。

has been blocked by CORS policy: No 'Access-Control-Allow-Origin'

泣く。

🙅 gapi.client.request()

まだだ。諦めない。
俺は長男だから我慢できたけど次男だったら我慢できなかった。

ドキュメントを漁り、もう1つの可能性を見つける。
クライアントSDKには標準で APIを叩く為のメソッド : gapi.client.request()が備わっている。
GoogleAPIのエンドポイントをベタ書きして引数に渡せば叩けるらしい。
クライアントSDKならCORSを回避できるんだろ?信じるぞ?
叩いてみる。

404

???
タイポ探しフェーズ(数時間)に入るも、やっぱり問題は無い。
リクエスト内容を確認した。POST先のURLが変わっている。
画像アップロードのエンドポイントはhttps://photoslibrary.googleapis.com/v1/uploadsだが、ドメイン部がcontent.googleapis.comに変わってる。
なんだこれは。
恐らく、クライアントSDKが書き換えていると推測。M*ther F*cker。

諦めた。

クライアントで完結することを。

🙆 Netlify Functions

ホスト先にNetlifyを利用している。
そこでクラウド関数を作れるNetlify Functionsを使ってみようと思い立った。
使った経験が無い機能だ。下記の記事を参考にHello Worldから始めてみる。
【入門】Netlify Functionsコトハジメ
そして「Googleに画像をアップロードしてアップロードトークン返すクラウド関数」を立ててみた。
うまくいった。
初めて使う機能なのに、ハマらず動く。ローカル環境もすぐ出来た。
感動した。モダン最高。

進捗:納期は昨日なので大丈夫です

この時点で日曜の夜。
軸となる機能は一通り完成したものの、「同名アルバムが存在していた場合」などのシチュエーションに応じた分岐は作れてないし、要件として「既にメッセージ画像が存在していた場合は更新する」といった機能も存在するが未実装だ。実機確認もやれてない。

「ごめんなさい」しつつ、月曜に出そう。
そう決めて床についた。

画像の削除/更新はできない

ベットに入った直後、悪寒がした。

見覚えがない。

散々GoogleAPIのドキュメントと格闘し続けたが、Google Photos内の写真を削除/更新するエンドポイントは見た記憶がない
スマホを取り出し確認してみるも、やはり無い。

冗談だろ?クラウドストレージを操作するAPIのくせに削除/更新は出来ない?そんな馬鹿な話があるか。
調べてみる。
https://issuetracker.google.com/issues/109759781
結論:出来ないらしい

詰みです。お疲れ様でした。

過去画像を含めてランダム表示されるなんてホワイトボードじゃない。

「お手数ですがGoogle Photosを開いて、ご自身で過去の画像を削除してください。」
しょーもな。
サービスとして成立していない。

・・・いや、まだだ。

よし、譲ろう。

百歩譲ろう。

Google Driveなら削除/更新もAPIで可能じゃなかろうか。
Photosなんかより歴史あるクラウドストレージだ。
Google Driveへアップした写真をNest Hubで表示できれば問題無い。

Can I connect my Google Drive to my Google home hub?
結論:出来ないらしい

こうして私は、web1weekの敗者となった。

Safari対応(おまけ)

負け犬でも遠吠えぐらいは出来る。
「手書き画像をGoogle Photosへアップするサービス」としてローンチしよう。
そう決めて実機確認に入った。

CORSぞ

MacのChrome、Android Chromeは問題なく動作完了。
妻に「iPhoneでこれ使えるか確認してほしい」と依頼する。
返信は「画面が驚きの白さ」。
ここで初めてMacのSafariでも動作確認してみる。

has been blocked by CORS policy: No 'Access-Control-Allow-Origin'

CORSぞ

何度立ちはだかれば気が済むのだCORSよ。
まぁ思い当たる節はあった。
GAPIが使い辛いので、下記のようにクラアントSDKを動的に読み込んでplugin化していたのだ。

export default async ({ $axios, store }, inject) => {
  // GAPIの読み込み
  const gapiScript = document.createElement('script')
  const src = await $axios.$get('https://apis.google.com/js/api.js')
  gapiScript.appendChild(document.createTextNode(src))
  document.head.appendChild(gapiScript)
  const gapi = window.gapi

  // ロード処理(Promise化)
  const clientLoad = new Promise((resolve, reject) => {
    gapi.load('client:auth2', () => {
      resolve()
    })
  })
  // GAPI初期化処理
  const init = () => {
    return clientLoad.then(() => {
      return gapi.client.init({
        apiKey: authConfig.Google.apiKey,
        clientId: authConfig.Google.clientId,
        discoveryDocs: authConfig.Google.discoveryDocs,
        scope: authConfig.Google.scopes.join(' ')
      })
    })
  }
  inject('gapi', gapi)
  inject('gapiInit', init)
}

不安はあったが、動いたのでそのままにしていた。
外部スクリプトを動的に読み込む場合、普通はCORSの問題が発生する。
素直にscriptタグで読み込むように変更した。

というか、なぜChromeでは読み込めたのか分からない。
同じGoogle製品だから?
プラットフォーマー恐るべし。

クロスサイトトラッキング

さて、初期表示までは問題なくSafariで動作した。
しかしGoogleへログインしても認証状態を検知しない。
プログラムが「ログイン中」と判断しないのだ。
こればっかりは思い当たる節も無い。

issueが上がっていた。
gapi auth2 issue on safari
同様の事象、かつオープンのままだ。
Safariの場合、Cookieを全削除するか、プライベートブラウズなら問題無いらしい。
マジかよ・・・
頑張れば何か対策できるのかもしれないが、負け犬にそんな根性は無い。
UserAgentから「Safari、もしくはiPhone/iPad」を判定して、該当する場合は長ったらしい注意文言を表示するようにした。

敗因

次回以降のweb1weekで勝つための敗因分析を行う。

PoC(概念実証)不足

一番の原因はこれ。
事前に「画像の削除/更新は出来ない」と知っていれば別案を採用していた。
とはいえ、1週間という過密スケジュールで検証工程を取れるかは疑問が残る。
少なくとも、

  1. 機能の洗い出し
  2. 実装に必要な外部インターフェースのドキュメントはちゃんと読む

まぁ、、当たり前のことはちゃんとしよう。ということ。

「出来ない」の判断が遅い

認証や画像アップロード等、ハマった際に「出来ない」という判断が出来ていない。
昨今の潤沢な開発環境に甘え、「出来ない事は無い」と思い込んでいる節がある。
確かに大抵の場合、ハマる原因は外部インターフェースの仕様変更、もしくはタイポが多い。
塩梅が難しいが、もっと早いタイミングでissueを探そう。

Google Photosを利用した画像アップローダーは作れるのか

冒頭に記した「裏の目的」について。
そもそもGoogle Photos APIの利用規約には「ホスティングサービスとして使うならCloud Storageを使え」と明記してある。
開発するサービスを「Google Photosへアップした写真の共有ギャラリー」という位置付けにするならグレーかな?と思ったが、
結論から言うと「多分、不可」だ。
少なくとも、そう言うサービスを作れるだけのAPIが提供されていない。

まず前述の通り、API経由での画像の削除/更新は出来ない。
あくまで画像のアップロードのみで、アルバム間の移動すら出来ない。

そして、更に致命的なことに静的なURLは取得できない
写真のURLを取得するAPIは存在するが、一定期間が過ぎると無効化されるらしい。
これはGoogle Photosの「URLで共有する」と同じURLなのだろう。
URL先には静的な画像が埋め込まれているらしいが、それを取得するAPIは存在しない。
スクレイピングを駆使すれば取得できるかもしれないが、実用的では無いだろう。

「ユーザーのプライベートストレージを用いた画像アップローダー」は別サービスを検討した方が良さそうだ。

ゴミを公開する

これはweb1weekじゃない。
私が作りたかったものでもない。
ゴミだ。

Nest Board
https://nest-board.netlify.com/

Google APIは申請無しで使用できるが、Googleから承認されていない場合、ログイン時に警告画面が出る。
利用する際は注意文言を読んで頂きたい。

需要あるか分からないが、Nest Hub側で表示する際の手順も明記しておく。

1. スマホ(iPhone/Android)でGoogle Homeアプリを開く
Nest Hubの初期設定に必要なアプリなのでインストールされているはず。

2. 表示したいNest Hub > フォトフレームを編集 > Google フォト の順で選択

3. Nest Boardで作成したアルバムを選択する
初期値は「Nest Board」

4. 「フォトフレーム」画面の下部にある「個人的な写真の整理」を「リアルタイム共有アルバムのみ」にする
これを設定しないとNest Hubがフォトフレームに不向きな写真と判定して表示されない。
ハマりポイント。

以上の手順を踏めば、Nest Hubに手書きのメッセージ画像が表示される。

敗北者じゃけぇ

普段はソースコードを公開したりしないのだが、今回はリポジトリをpublicにした。
なぜなら敗者だからだ。
https://github.com/kin-mi/nest-board

おわり

次は勝つ。

ツイッターでシェア
みんなに共有、忘れないようにメモ

きんみ

Crieitは誰でも投稿できるサービスです。 是非記事の投稿をお願いします。どんな軽い内容でも投稿できます。

また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!

有料記事を販売できるようになりました!

こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください。
ボードとは?

コメント