Articles
ウェブサイト作った
ウェブサイトできました 自分のウェブサイト兼ポートフォリオサイトを作った 使用したフレームワークは Next.js, ホスティングは GitHub Pages を使用 Articles にはQiita/Zenn/sizu.me等で書いた記事をまとめて置いておくつもり ちなみに ArticleはMarkdownファイルをビルド時に読み込んで生成しています。 Markdown の描画・パースには Zenn 公式のライブラリを使いました。 以下該当のコード: import path from "path"; import fsPromises from "fs/promises"; export type Article = { id: string, title: string, filename: string, created_at: Date, markdown: string, } type JSONArticle = Omit<Article, "created_at" | "markdown"> & { created_at: string, } function parseJSONArticle(data: any): JSONArticle | undefined { const d = data as Partial<JSONArticle>; if ( !( typeof d.id === "string" && typeof d.title === "string" && typeof d.filename === "string" && typeof d.created_at === "string" ) ) { console.error("data is not RawArticle"); return undefined; } return d as JSONArticle; } async function fetchArticleMarkdown(filename: string) { const filePath = path.join(process.cwd(), "assets", "articles", filename); const buffer = await fsPromises.readFile(filePath); return buffer.toString(); } function parseArticle(data: JSONArticle, markdown: string): Article | undefined { try { const createdAt = new Date(data.created_at); return { id: data.id, title: data.title, filename: data.filename, created_at: createdAt, markdown } } catch (e) { console.error("Can't parse created_at to date in article_data.json"); return undefined; } } async function fetchJSONArticles() { const filePath = path.join(process.cwd(), '/assets/article_data.json'); const buffer = await fsPromises.readFile(filePath); const objectData = JSON.parse(buffer.toString()); if (!(objectData instanceof Array)) { return Promise.reject("article_data.json is not array."); } return objectData .map(d => parseJSONArticle(d)) .filter((d): d is JSONArticle => d !== undefined); } export async function fetchArticles() { const jsonArticles = await fetchJSONArticles(); const promises = jsonArticles .map(async (d) => { const markdown = await fetchArticleMarkdown(d.filename); return parseArticle(d, markdown); }); const articles = await Promise.all(promises); return articles .filter((article): article is Article => article !== undefined) .sort((a, b) => b.created_at.getTime() - a.created_at.getTime()); } export async function fetchArticle(id: string) { const jsonArticles = await fetchJSONArticles(); const jsonArticle = jsonArticles .find(article => article.id === id); if (jsonArticle === undefined) { console.error(`Not found article data: ${id}`) return undefined; } const markdown = await fetchArticleMarkdown(jsonArticle.filename); const article = parseArticle(jsonArticle, markdown); if (article === undefined) { console.error(`Article of ${id} is undefined.`) return undefined; } return article; }
12/12/2023
Firestoreのページング方法3選
GDSC Japan Advent Calender 2023 1日目 はじめに 先日、Firestore を使用してとあるアプリを開発していたところ、以下の機能が必要になりました。 データを1画面に複数件表示し、複数ページに分ける ページ番号を指定して表示する 作成日でソートし、昇順・降順を切り替える どの期間のデータを表示するかを指定可能 この機能を実装するためにあれこれ調べたので、これを例としてFirebase Firestoreでページングを実装する方法を3パターン紹介します。 ところで: RDBではどう実装するのか? FirestoreはNoSQLですが、実装方法を考えるためにMySQLなどのRDBでページングを実現する方法も調べてみました。 どうやら以下の2つの方法があるようです。 オフセット法 シーク法 オフセット法は、単純にデータをある順番に並び替えたあと、前から n 番目以降を m 件取得というクエリを発行する方法です。メリットは実装が単純なこと、デメリットは読み出し速度が遅いこと。 シーク法は、前ページの最後のデータをキーにしてクエリを発行する方法です。メリットは読み出しが早いこと、デメリットは実装が複雑なことと任意のページに飛んで表示するのが面倒なこと。 参考1: offsetでページネーションは遅い。これからはシーク法だ! 参考2: で、オフセット法に比べてシーク法のページネーションはどれだけ早いの?RDB毎に。 以上を参考に、Firestoreのページングについて考察していきます。 案1: オフセット法 Firebase Admin SDK (サーバーが使うやつ) には, MySQLのような offset が実装されています。 API Reference const firestore = getFirestore(); firestore.collection('foo').limit(10).offset(20); メリット 実装が割と簡単 (なように見えて、Admin SDKなのでバックエンドのAPIを用意しないといけないため割と面倒) デメリット 読み飛ばした分も課金対象になる Admin SDK はセキュリティルールが (恐らく?) 適用されないため、DBのアクセス権限を別途で実装しなければいけない 作成日降順でソートしているとき、あるページを読み込んだ後に新しいデータが追加されると次ページを表示したときに同じデータが表示される 詳しくはこちらを参照してください。 コスト 手軽さ パフォーマンス ☆☆☆ ★★☆ ? 案2: シーク法もどき Firestore公式は (シーク法という言葉は使っていないものの) シーク法のアルゴリズムでページングすることを推奨しています。 ただ、シーク法には任意のページだけを取得する、言い換えると前ページのデータを取得していない状態でページを取得することが難しいという欠点があります。 MySQL等では各ページの最後の値だけを取得するクエリを発行できますが、現状Firestoreではそのようなクエリを発行できません。 解決策として、Firestoreのドキュメントにindex (ソートされた状態で前から数えた番号)を持たせます。 例えば作成日順でソートする場合、新規作成データのindex = 最新データのindex + 1 となります。 読み取り時は startAt() でオフセット分だけずらして取得します。以下は読み取りコードの例です。 // 昇順の場合 const limPerPage = 10; // 1ページあたりに表示する件数 const page = 2; // 表示したいページ番号 const fooCollectionRef = collection(db, 'foo'); const offset = (page - 1) * limPerPage; // ここがキモ、indexをページ分ずらす const ascQuery = query(fooCollectionRef, limit(limPerPage), orderBy('index', 'asc'), startAt(offset)); // 降順の場合 const limPerPage = 10; // 1ページあたりに表示する件数 const page = 2; // 表示したいページ番号 const maxIndex = 100; // コレクション内で最大ののindex, 事前に取得しておく const fooCollectionRef = collection(db, 'foo'); const offset = (page - 1) * limPerPage; // ここがキモ、indexをページ分ずらす const ascQuery = query(fooCollectionRef, limit(limPerPage), orderBy('index', 'desc'), startAt(maxIndex - offset)); ある期間に作成されたデータを取得したい場合、事前にその期間の最初または最後にあるデータの index を取得してクエリに渡します。 // ある期間以降の最初のデータのindexを取得する const from: Timestamp = // Timestampで期間を指定 const fooCollectionRef = collection(db, 'foo'); const q = query(fooCollectionRef, limit(1), orderBy('created_at', 'asc'), startAt(from)); // created_at はドキュメントの作成日 const snapshot = await getDocs(q); if (!snapshot.empty) { // クエリに一致するドキュメントが存在すれば const fooData = snapshot.docs[0].data(); const startAtIndex = fooData.index; } メリット オフセット法よりコストが低い maxIndex を更新しない限り、ページを送ったときに同じデータが表示される問題が発生しない デメリット 作成順以外でソートしたい場合、データが追加されるたびに既存のドキュメントの index を再設定する必要がある。 例えば、以下のようなコレクションに新しく金額が¥150のデータを追加する場合、金額が¥200, ¥400のデータのindexに1加算しなければなりません。 index 金額 1 ¥100 2 ¥200 3 ¥400 キーとなる index をクライアント側で保存する必要がある 読み取り時、最大の index を取得するために余分に1だけ読み取りが発生する コスト 手軽さ パフォーマンス ★★★ ★☆☆ ★★★ 案3: BigQueryを使う BigQueryはDWH(データウェアハウス)の一つで、標準SQLでクエリを書けます。 また、Firestoreの"Stream Firestore to BigQuery"というExtensionを使用して、FirestoreのデータをBigQueryに流すことができます。 RDBとDWHは微妙に違うようで、MySQLともSQLの仕様が少し異なるらしく、先に述べたRDBでシーク法を実装するSQL文は使えませんでした。 (アドベントカレンダーの公開まで間に合わないので) 実際のクエリは省略します。 (BigQuery慣れてないので嘘言ってたらごめんなさい) <details><summary>参考: BigQueryの料金</summary> 対象 料金 無料枠 ストリーミング挿入 $0.012/200MB なし クエリ(オンデマンド) $6.00 per TB 毎月 1 TB </details> コスト 手軽さ パフォーマンス ★★☆ ★☆☆ ? まとめ 以上の3つをページングの手法として挙げましたが、個人的にはシーク法(もどき)をおすすめします。 要件や規模にもよりますが、任意ページ目を取得する機能を諦めるならソートするフィールドによらずにページングを実装できます。 そもそも複雑な複合クエリが必要となるシステムでは、NoSQLではなくRDBを採用するでしょうからね。 Firestoreを使ったシステムならシーク法で十分でしょう。 コスト 手軽さ パフォーマンス オフセット法 ☆☆☆ ★★☆ ? シーク法もどき ★★★ ★☆☆ ★★★ BigQuery ★★☆ ★☆☆ ? 参考サイト https://mokajima.com/how-to-paginate-data-in-cloud-firestore/https://mokajima.com/how-to-paginate-data-in-cloud-firestore/ https://qiita.com/madilloar/items/5625e61cf3e348d08ef8#%E5%8B%95%E6%A9%9Fhttps://qiita.com/madilloar/items/5625e61cf3e348d08ef8#動機
12/1/2023
大学でアプリ開発したけどあんまり上手くいかなかった話
まえがき 先日、うちの大学の某サークル様向けにとあるアプリを作った。よそにアプリを納品するのは初めての経験で、(主に失敗から)学んだことが多くあったので備忘録的にまとめてみる。 ちなみにアプリの表向きな開発元はここ 作ったアプリについて軽く紹介 納品先は学祭や地元のイベントでコーヒーのドリップ販売を行っているサークル。今までは紙の番号札で注文を管理していたため、コーヒーを購入したお客さんは必ず列に並んで長時間待つ必要があった。今回作成したアプリは、注文管理やコーヒーの作成を全てシステム化し、待ち時間を可視化して列に並ぶ必要をなくすのが目的だった。 アプリの操作フローは レジが注文を追加する。 バリスタ(コーヒーをドリップする人)が手持ちのスマホで注文された商品のステータスを「作成中」に変更すると同時に、商品を作り始める。 バリスタが商品を作り終えると、端末から商品のステータスを「完成」に変更する。 お客様に商品を渡すと同時に、注文を「受け取り済み」に設定する。 失敗した点 アプリが使われなかった アプリが使用される予定だったのは、11/3から11/5にかけての学祭で出店される屋台。その3日間のうち、実際にアプリが使用されたのは1日目と2日目の午前だけ。その後はアプリの使用方法の周知が行き届いていなかったために使用されることはなかった。 仕様の考察が不十分だった 本質的な失敗はこれに尽きると思う。学祭では、納品先の屋台に例年100人単位でお客さんがやってくる。ピーク時には何十人分の注文を待つ場合もある。そんな中で、前述した操作フローはあまりに煩雑すぎた。 アプリの動作が不安定だった 操作フローを見ればわかる通り、このアプリは複数端末で同じデータを参照するため、リアルタイム更新を実装した。でもわたしの実装が悪かったのか、ときたまリアルタイム更新が動かなくなることがあった。また慣れないアニメーションを実装したためか、端末によってはアプリの動きが重くなってしまった。 反省点 クライアントの状況を想像する 今回わたしは「いかにきれいなロジックで実装するか」を重視してシステムの設計を行った。でも実際大切なのは「需要にどれだけマッチしているか」「運用されるときにどれだけ使いやすいか」だった。今考えると当たり前のことだけど。 仕様と設計をよく考察する 開発中、仕様変更が何度もあった。これに合ったシステム設計にするため、リリースの1週間前にDBの構造含めロジックを大幅に変更したのだが、結局その後の仕様変更で設計を変更した意味が薄れてしまった。必要な仕様について、それが本当に必要なのか、ベターな案はないかを考えるべきだった。設計も私がメインでやったけど、もっとチームメンバーと議論するべきだった。 おわりに 私の拙い文章を読んでくださってありがとうございました。技術的な失敗点はまた別の機会に書こうと思います。
11/8/2023