序
私は本が好きです。1
著者、つまり自分とは別の人の考え方や、その人が考えたことを知ることができるというのが好きな理由なわけですが、同じ理由で、書評を読むことも同じくらい好きです。
できれば、書評だけではなく、その人がその本を読みながら、どんなことを考えたのかということまで知れたらなお良いです。
- これはある種、野卑な欲求かもしれませんが…2
で、まずは自分からということで、自分が本を読むにあたり、「どの辺を読んでいるときに」、「どんなことを考えたのか」というのを記録して、あわよくばそれ自体がコンテンツになるといいなと思ったので、そういうことができるWebアプリを作りました。
結果、読書タイマーと読書メモと蔵書登録と書籍レビューが一つにまとまったようなサイトができました。
Googleアカウントで使えますので、よかったら試してみてください。
自分の書いた読書記録と書評はXにリンクをシェアしたり、Markdown形式でコピーしたりできるようになっているので、読書活動のコンテンツ化にも一役買います。
主な機能
-
- 各種APIを使って書籍を検索し、本棚に登録します
- 今読んでる本、読み終わった本以外にも、読みたい本、積んでる本、なども登録できます
-
- 本棚から読みたい本を選んで、目標時間を決め、スタート
- 積算式に時間をカウントします
- 目標時間を超えたら表示色が変わります。ユーザーにお知らせはしません
- 読んだら読んだだけ偉いという思想でこうなってます
-
読書メモ&読書活動の記録
- 読書タイマーの動作中にメモを取れます
- 読書を終えるタイミングで、今回の読書の感想や総括を入力します
-
本の評価
- 本の評価と感想を登録できます
-
シェア
- 読み終わった本の感想や、日々の読書記録はMarkdown形式でコピーしたり、Xにシェアできます
- 読書活動そのものがコンテンツになる、ということは、実質的にインプットがアウトプットになるわけです
- 読み終わった本の感想や、日々の読書記録はMarkdown形式でコピーしたり、Xにシェアできます
-
読書を促す通知機能3
- 設定画面で決めた時間に、設定画面を操作した端末に対して、WebPush通知が来ます
- PWAとしてインストールしている時だけ設定できるようにしています
- 通知機能は十分な負荷検証ができていないので、現在はベータ版の位置づけです
- 人が増えたとき、Workers FreeのCPU時間10msでいけるのかという問題
ユースケース
想定する使い方はこんな感じ。
通知が来たらスマホ(またはPC)でYOMINAを開いて、紙の本や電子書籍リーダーで読書をしつつ、ときどきYOMINAでメモをとる
- 物理本やKindleなど読書端末を使う場合はこの形
iPadなどのタブレットをランドスケープモードにし、スプリット機能で片側にYOMINA、片側に電子書籍リーダー、という配置で読書しつつYOMINAでメモを取る
- 電子書籍ならこの使い方がイチオシです。キーボードの配置を変更できるタブレットなら、なおよいです
スマホ1台で、YOMINAをバックグラウンドで動かしつつ電子書籍リーダーを起動して読む
- あまり体験が良くない使い方です
- タイマーが止まるし、メモのために切り替えるのは面倒
- 「読書したという記録をとる」という目的であれば、読書時間だけ手で入れたらよいので、使えないことはないです
参考
参考までに、ここ2週間ほど運用した、私のユーザーページを貼っておきます。
感想は必須入力にはなってないので、書きたいときに書きたいだけ書くような、気軽な運用にしています。
- メモを取らなきゃ、感想書かなきゃ、というプレッシャーが読書を邪魔してはいけないので
自分で作ったのでそりゃそうなのですが、気に入って便利に使えてます。
YOMINAの構成要素
単なる宣伝にならないように、ここからは構築基盤とかフレームワークの話をします。
今回の構築にあたり重視したのは、とにかく運用コストを抑えること、具体的には自分しか使う人がいない場合でも許容できるコストに抑えることです。45
ここでいうコストというのは金額だけではなく、環境の維持管理にかかる手間も含めてのことです。
- OSの面倒は見たくないのでIaaSは除外
- ネイティブアプリは自分のスキルセット的に厳しいし、開発者登録にお金もかかるので、Web(+PWA)で実現できる範囲で
- Next.js+Vercelは商用利用(アフィリエイトリンクがあるため)だと$20/month〜+別でDBも何か用意しないといけないのでしんどい
-
Firebase+RemixのSPAモードというのも考えましたが、外部のAPIを叩いたりする関係上、どうしてもBFF的な薄いバックエンドをどこかに構築しなければならず、じゃあCloud FunctionsでとなるとBlazeプランが必須になってしまうのがちょっと…
あと、Cloud Functionsのエミュレーターって開発体験いまいちじゃないですか?
ということで、少し前に触ってみて開発体験の良かったCloudflare PagesにRemixのSSRを載せることにしました。
思想
YOMINAはUGCをメインコンテンツとするサービス6ですので、正式にリリースした時点で、それなりに長期間サービスを維持する責務が生じます。というか、この手のサービスは、続きそうじゃないと誰も使ってくれません。
YOMINAを情報の置き場として7信頼してもらうために必要なことは何か、を自分の視点で考えてみると、
- 長く運用すること
- データにポータビリティを持たせること
この二点が重要になると考えました。少なくとも私が自分で使うサービスを評価するときの基準はそうです。
コストをかけないインフラのチョイスも、一応のマネタイズとしてアフィリエイトを組み込んでいるのも、入力コンテンツをMarkdownで気軽にコピーできるようにしてるのも、この基準を満たすことがモチベーションになっています。
Remix
- Reactで開発したかったので、フレームワークはRemixにしました
- Next.jsでも良かったのですが、RemixのLoader→Component→Actionというデータフローがわかりやすかったし、何より一度触ってみたかったのでこれにしました
-
loader
はGET
に、action
はPOST
(を含むその他のメソッド)に対応するので、コンポーネントを定義せずに、データ登録した後リダイレクトするエンドポイントとか、データ取得だけする共通のエンドポイントとかも作れます- YOMINAでいうと、「本棚に登録する」ボタンがその例で、専用のフォームとエンドポイントをセットで定義して、いろんなところから呼べるようにしています
Cloudflare
- 基本的に全部Cloudflareに乗っかる形にしており、RemixのホスティングはPages、通知の処理にWorkers、DBはD1、画像やSQL結果のキャッシュにWorkers KVを使っています
- アバター画像もKVに突っ込んで保管してます
- よほどのことがなければ無料枠に収まる想定でいます
- 仮に超えても、月$5のPaidプランならお小遣いで何とかなります
- ドメインもCloudFlareで取りました
- 値段と見た目のバランスから、
*.app
にしました - ほんとは
*.io
ドメイン欲しかったけど、値段が…
- 値段と見た目のバランスから、
書籍検索
- 書籍検索は、現時点では楽天ブックスAPIと、Google Books APIを利用したものになっています
- 書籍のデータはYOMINA側でキャッシュし、アクセス時の鮮度に応じて不定期にリフレッシュします
- 一度YOMINAに登録された本は、YOMINA内検索で検索できます
- AmazonのPA-APIも使いたいのですが、売り上げがないので凍結中…
- 使えるようになったら実装予定です
ユーザー認証
- Remix-Authを使って実装しました
-
コミュニティストラテジーから、
remix-auth-google
を使っています - メールアドレス認証は別に要らないかなと思ってオミットしました
- 「インターネットを使っていて、Googleアカウント持ってない人はごく少数」という認知なのですが、これって歪んでるでしょうか?
- Googleアカウントはあるけど、どこの馬の骨とも知れないWebサービスに紐づけたくない、という心情はわからなくもないので、ちょっと迷いどころですが…
その他の構成要素
-
Remix-PWA
- Web Pushを使いたかったので、PWA化しています
- ただし、オフラインでは動きません
- とりあえずタイマーだけはオフラインでも動くようにしようかなと画策中です
- Web Pushを使いたかったので、PWA化しています
-
Bun
- 開発環境として
- 依存関係のインストールが劇的に速くて快適なのでこれを使ってます
-
Drizzle ORM
- ORマッパー。D1との接続はチュートリアル通りにやれば簡単です
-
TailwindCSS
- スタイリング
-
Conform + Zod
- フォームの構築とバリデーション
-
HeadlessUI
- トーストやドロワーのUI構築にTransitionを使用
マスコットのヨミナちゃん8はDALL-E 3に作ってもらいました
苦労したところ
割と素直に実装できたと思ってはいるのですが、それでもいくつか苦労した部分があります
時間
- 年明けぐらいから漠然と考えてたので、大体構想2か月、実装は足掛け5か月ぐらいかかってるはずです
- 隙間時間でやってるにしろ、もうちょっと早く形にしたかったなぁというお気持ち
- とはいえ、それでもコツコツ実装していたらちゃんと形になるのだからいいものです。これがプログラミングのいいところです
- コミット量からの類推ですが、書いたコードの量は15,000〜20,000行の間ぐらいだと思います
- GitHub Copilotも活用したので、掛けた時間に対してコード量がだいぶ多い印象です
- 私は自分が一人で全体を掌握できるコードベースのサイズは10,000~頑張っても15,000行ぐらいだと思っており、YOMINAは結構しんどいコード量になりつつあります
ふわふわ設計
- 一応、ざっとワイヤーフレームを紙で書いてから作り始めましたが、結果的にあれこれ必要な画面が増えて、当初考えた形より大掛かりなWebアプリになってしまいました
- いったんこの辺でコメントを整備したり、テストをもっと書いてリファクタリングしやすくしたり、というところをやらないとなぁと思っています
ファイルベースルーティング
- Remixのファイルベースルーティングは結構しんどみが深いです
- ページ数が増えると
route
フォルダの直下が凄まじいことになります - 最初のうちは、命名規則が
気持ち悪いなあまり直感的じゃないなぁと思いながらも、似た位置のルートはファイル名でソートすれば似た位置になるので、そんなに悪くないじゃんと思ってました - しかしルートが30を超えたあたりから、とにかく
routes
フォルダが縦長になり、様相が変わってきます - なにより辛いのが、VSCodeのエクスプローラーで表示するときこれらがAll or Nothingになるということです
- ページ数が増えると
コロケーション(むりやり)
- YOMINAでは各画面のフォームをコロケーションしたかったので、
*.Form.tsx
をルートから除外するように設定して配置しています
remix({
ignoredRouteFiles: ["**/*.Form.tsx"],
}),
- そのせいで
routes
フォルダがさらにカオスに… - 参考までに、YOMINAぐらいの規模だと、だいたい70ファイル程度がフラットに配置されることになります
- うち、フォームが16個ぐらいあります
- remix-flat-routesを使えばかなり整理できそうなので、押しつぶされないうちに導入しようかなと考え中です
action
カオスになりがち問題
- Remixは一つの画面で複数の処理をしたい場合、
action
がカオスになりがちです-
*.Form.tsx
から、zodのスキーマ、action
ハンドラー、Form
コンポーネントをexportする -
Form
コンポーネントの<input type="submit" />
は属性としてname="action"
とvalue="<アクション名>"
を持つ
-
- というルールにして、ルート側からは
submit
のvalue
で分岐して、該当するフォームのaction
を呼ぶように実装しました- これ、何かもっといい方法があったら教えてほしいです
const action = formData.get("action");
if (action === "delete") {
return await deleteAction(formData, args.context.cloudflare.env);
} else if (action === "edit") {
return await editAction(formData, args.context.cloudflare.env);
}
YOMINAで言うと、ユーザー側の「読書記録」の単件表示の画面(/logs/$logId.tsx
)は、読書記録本体の編集
・削除
+ 読書メモの追加
・編集
・削除
の計5アクションが犇めく魔境と化しました。
あまり詰め込みたくはなかったんですが、読書メモの単件画面は労力的にもUX的にも作りたくなかったので…。
https
使えない問題
- Cloudflare Workersはnodeじゃないので、
https
を使うパッケージが動かないようです- 通知のためのWorkerを別で一つ動かしているのですが、
web-push
も@remix-pwa/push
もダメだったので、いろいろ探した結果、webpush-webcryptoを使って実装したら動きました
- 通知のためのWorkerを別で一つ動かしているのですが、
サボるタイマー問題
-
setInterval
はタブが後ろに隠れたりするとサボってちゃんとカウントしなくなるので、タイマーはWeb Workerとして実装しています
ちらつくダークモード問題
-
localStorage
に設定を保存しているのですが、初回表示やリロードがちらつきます。なんとかしたい- ダークモードって、ちゃんと実装するの難しいですね…
D1+Drizzle ORMにおけるjoin
の挙動問題
外部APIのレートリミット
- Rate Limitingが使えると思ってたのですが、これは、Pagesだと使えません(2024/7現在)
- 仕方ないので、KVにAPIごとの前回実行時刻を持つようにして、レートリミットを掛けています
- KVは、Writeがグローバルに伝播するまで最大60秒ほどかかるのが若干の不安要素ですが、基本的には日本でしか使われない想定なので、多分大きな問題はないはず…
- 楽天APIを結構安直に呼んでしまう仕組みにしてしまったのですが、ユーザー増えたら厳しいかも、という気がしています…
- 楽天API呼びすぎて止められてしまうとサービスに本を登録する導線が死ぬので、レートリミットは順守しなければならない
- 今後、単なるレートリミットではなく、キューイングの仕組みもいるかもしれないです
- 楽天APIを結構安直に呼んでしまう仕組みにしてしまったのですが、ユーザー増えたら厳しいかも、という気がしています…
- KVでの結果キャッシュなども組み込んだ結果、だいぶ検索がもっさりになってしまった感があります。何かいい方法がないか模索中です…
読書セッションのレジューム
- 読書タイマーの動作中にブラウザを閉じたりしてもいいように、読書セッションのレジューム機能を実装しています
- Remixの
useBeforeUnload
を使って、localStorage
に保存して、再表示時に読みだす、という形で、一応頑張って読書セッションを維持しようとします- つまり別の端末/ブラウザで開いたらダメ
- クライアントサイドで全部処理してるので、結構頑張って実装した割に、普通に切れます。悲しい
- グローバルなステート管理であり、挙動も複雑なので、あんまりうまくいっている感じがしません
- 本気で実装するならセッション管理はサーバー側に移して、ServiceWorkerと連携させるなどする必要がありますが、結構作業量がありそうなので躊躇しています…
これから
現時点で未実装ですが、先述の通り、データのポータビリティは重視したいと思ってるので、本棚と読書記録のエクスポート機能をおいおい実装する予定です。
また、評価軸を「面白かったか」と「人に薦めたいか」の二軸に分けてるので、自分と同じような趣味の人がおすすめしている本をパッと検索できるようにしたいです。これがこのアプリを作った最大の目的といっても過言ではありません。ユーザーがたくさんいてくれてこその機能だと思うので、使う人が増えてくれると嬉しいなあと思います。
リコメンド機能や、メモのとりまとめ機能として生成AIを組み込むのもよいかもしれません。
あと、個人的に悩ましく思っているのがISBNコードのない雑誌や電子のみの書籍、Zenn本などの扱いで、これらは現在YOMINAでは扱えません。例えば、Software Designなんかは勉強の記録を取りたかったりするので、何とか登録できるようにしたいなと思っています。
UdemyやYouTubeなどの動画や、PodCastをインプットにしてる人も多いと思うので、この辺を全部抽象化して、最終的にはインプット支援プラットフォームというところまでいけるといいなと。
終わりに
壮大な野望を語ったところで、記事はこのぐらいにしたいと思います。
重ねての記載になりますが、Googleのアカウントがあればすぐに使えますので、ぜひお試しいただけると嬉しいです。
-
ただし、読書量は並かそれ以下 ↩
-
時間中途半端すぎんか ↩
-
最低でも1名のユーザーが確保できるという意味で、自分が使いたいものを作ることを意識しています。こうすると、インフラ費用が擬似的にサブスク料金の位置付けになるので、サービスを維持する心理的なハードルが下がると思ってます。 ↩
-
この点、個人開発で最低でも2名利用者がいないと成り立たないタイプのサービスを展開している人はすごいなと思います。 ↩
-
私の日記帳として細々と維持される未来も、もちろんありえます。でもそれはそれでいいのです。YOMINAにはポートフォリオとしての側面もあります。 ↩
-
アプリとしての手触りが良いとか、品質が高いと感じられる、といった、使いたいと思うかどうかの次のハードルとして。 ↩