はじめに
私は現在ITコンサルタントの会社でパートとして勤務し、システムの開発に携わっています。また実務と並行してPythonスクールにも入り、毎日プログラミングの勉強をしています。
今回作成した読書記録アプリは、Pythonスクールで学んだ内容をアウトプットすることを目的に作成しました。
そして本記事は、アプリ開発を通して気づいたことや学んだこと、苦労したことなどを残すための学習記録として書きました。
1.アプリの概要
読書記録アプリBookGramの機能について説明します。
基本的な機能は以下です。
・アカウント作成
・ログイン/ログアウト
・本の検索
・本をマイライブラリに保存
1-1 アカウント作成機能
ユーザーが自身のアカウントを作成し、マイライブラリを利用できるようにする機能です。
ユーザー情報はデータベースで管理し、パスワードはハッシュ化したうえで登録することでセキュリティを考慮しています。
また、ペンネームとメールアドレスはユニーク制約とし、すでにデータベースに存在する場合はユーザーにエラーメッセージで表示します。
1-2 ログイン/ログアウト機能
登録済みユーザーがログインし、認証された状態でのみマイライブラリを利用できるようにする機能です。
本の検索はユーザー登録をしなくても利用可能にし、検索した本の保存はログイン必須としました。
ログイン状態はFlask-Loginによるセッション管理をし、認証が必要なページには未ログイン状態で直接アクセスできないよう制御しています。
1-3 本の検索機能
キーワードや書籍名、著者名を入力することで、書籍情報を検索できる機能です。
外部の書籍情報API(Google Books API)と連携し、バックエンド側でAPIリクエストを行う構成としました。
APIキーは環境変数で管理することでコードから分離し、セキュリティを意識しました。
また、検索結果が多くなることを想定し、APIのページングパラメーターを使ったページング処理を実装することで、応答時間の短縮とサーバー負荷の軽減やUXの向上も意識しました。
1-4 マイライブラリに本を保存する機能
検索した書籍を、ユーザーごとのマイライブラリに保存できる機能です。
ユーザーは保存した書籍に対して、「読みたい」「読書中」「読了」といった読書ステータスの管理、評価やメモ、読書開始日や読了日の登録を行うことができます。
実装面では、ユーザー情報と書籍情報を分離し、両者を紐づける中間テーブルを用いることで、同一の書籍を複数ユーザーが管理できる構成としました。
2.データベース設計(ER図)
ユーザー(USERS)と書籍(BOOKS)は多対多の関係となるため、中間テーブルとしてLIBRARY_ITEMSを設けています。
LIBRARY_ITEMSでは、ユーザーごとの読書ステータス(status)や評価(rating)、
メモ、読書期間などの情報を保持できるように設計しました。
この構成により、同一の書籍を複数ユーザーが管理できるだけでなく、読書履歴の管理や将来的な機能追加にも対応できるようにしました。
3.使用技術と全体構成
本アプリは、Flaskを用いたWebアプリケーションです。
ユーザー操作はブラウザから行い、リクエストはFlaskで受け取ります。
Flaskは、外部APIとの通信を行い必要なデータを取得したり、データベースへの保存・取得をします。取得したデータはJinja2テンプレートを通してHTMLに反映し、画面に表示しています。
| 分類 | 使用技術 | 役割 |
|---|---|---|
| フロントエンド | HTML/CSS/JavaScript | 画面表示・操作 |
| UI | Bootstrap | レイアウト |
| バックエンド | Python/Flask | API・認証処理 |
| DB | PostgreSQL | データ保存 |
| 外部API | Google Books API | 書籍情報の取得 |
| デプロイ | Heroku | アプリ公開・運用 |
4.詰まった点・苦労した点
今回のアプリ開発では、主に「認証・認可まわりの設計」に苦労しました。
とくに「ログインしているのに、他人のデータが見えてしまう」という致命的な問題に直面し、私のIDORに関する知識不足を痛感しました。
4-1 他のユーザーのデータが見えてしまう
アプリを一通り実装したあと、テストアカウントを2つ作成しました。1つのテストアカウントでログインして動作確認をしたところ、もう一方のテストアカウントのマイライブラリが表示されてしまうという問題が発覚しました。
原因を調べていくと、マイライブラリ一覧の取得で「ログインしているか」は確認しているが、「ユーザーに紐づいたデータか」の制御ができていないという状態になってることが原因だとわかりました。具体的には、一覧表示の際に全ユーザー分のレコードを取得してしまい、「ログインしていれば誰でも他人のデータにアクセスできる」状態になっていました。
↓ 最初に書いたコード
@app.route("/library")
@login_required
def library():
library_items = LibraryItem.query.all()
return render_template("library.html", library_items=library_items)
↓ 修正したコード
@app.route("/library")
@login_required
def library():
library_items = LibraryItem.query.filter(LibraryItem.user_id == current_user.id).all()
return render_template("library.html", library_items=library_items)
filter()でcurrent_userと紐づいたLibraryItemだけを取得するようにしました。
4-2 URLのidを変えるだけで他人の情報にアクセスできてしまう
最初は「マイライブラリ一覧ページを修正すれば、そのあとにアクセスするライブラリの本の詳細ページ等も、認証したユーザーに紐づいたデータだけを表示できる」と考えていました。しかし実際には、以下のような問題がありました。
-
/library-items/<id>/detailの<id>を別ユーザーのものに変えると、他人の詳細が見えてしまう
- さらに編集/削除も同様に、id を変えるだけで操作できてしまう
↓最初に書いたコード
@app.route("/library-items/<int:library_items_id>/detail", methods=["GET", "POST"])
@login_required
def libraryItem_detail(library_items_id):
library_item = LibraryItem.query.get_or_404(library_items_id)
return render_template("library_item_detail.html", library_item=library_item)
上記のコードは、指定したidのlibrary_itemがテーブルに存在しない場合は404のエラーページを返してくれます。しかし、テーブルにデータが存在する場合に、それがcurrent_userのidと紐づいているかのチェックを行っていないので、URLのidを変えれば他人のデータにもアクセスできてしまいます。
↓修正したコード
@app.route("/library-items/<int:library_items_id>/detail", methods=["GET", "POST"])
@login_required
def libraryItem_detail(library_items_id):
library_item = LibraryItem.query.get_or_404(library_items_id)
if library_item.user_id != current_user.id:
abort(404)
return render_template("library-item-detail.html", library_item=library_item)
取得したlibrary_itemのuser_idと現在ログインしているcurrent_userのidが異なる場合、エラーページを表示するコードを入れました。

4-3 ユニーク制約でNULLのときにNoneの文字列が入ってしまう
テーブル設計を考える際に、同じ本を重複してデータベースに保存しないように本を識別するものにユニーク制約を付けることとしました。
将来的にはGoogle Books APIから取得した書籍だけでなく、ユーザーが登録した書籍情報もBOOKSテーブルに保存できるようにしたいと考えていたため、世界共通の国際標準図書番号であるISBNをユニークにしようと考えました。
しかしISBNが付いていない本やAPIデータもあるため、ISBNが存在しない場合に重複の判断とするためGoogle Books APIの独自IDであるvolume_idもユニーク制約を付けました。
↓最初に書いたコード
volume_id = request.form.get("volume_id")
book = Book.query.filter_by(volume_id=volume_id).first()
# Bookテーブルに本がなければ保存
if not book:
title = (request.form.get("title") or "").strip()
author = (request.form.get("authors") or "").strip()
isbn = (request.form.get("isbn") or "").strip()
isbn_low = isbn.lower()
if isbn_low == "none" or isbn_low == "null" or isbn_low == "":
isbn = None
page_count = request.form.get("pageCount")
description = request.form.get("description")
cover_image_url = (request.form.get("cover_image_url") or "").strip()
cover_img_url_low = cover_image_url.lower()
if cover_img_url_low == "none" or cover_img_url_low == "null" or cover_img_url_low == "":
cover_image_url = None
volume_id = request.form.get("volume_id")
source_type = request.form.get("source_type")
book = Book(title=title, author=author, isbn=isbn, page_count=page_count, description=description, cover_image_url=cover_image_url, volume_id=volume_id, source_type=source_type)
db.session.add(book)
db.session.commit()
私は当初、ISBNがない場合データベースにはNULLが保存されるものと思っていました。しかし実際にはNoneが文字列として扱われISBNカラムには"None"という文字列が保存されていました。
そのためISBNがない書籍はISBN="None"になりNoneが重複してしまうため、違う書籍なのにユニーク制約でデータベースに保存できない問題が発生しました。
「データベースに書籍がない場合」という条件から、「同じISBNが存在する場合 → 同じISBNがないときでvolume_idがある場合 → 同じISBNもvolume_idもなかった場合」の三段階でチェックするように変更しました。
↓修正したコード
volume_id = (request.form.get("volume_id") or "").strip()
isbn = (request.form.get("isbn") or "").strip()
volume_id_low = volume_id.lower()
isbn_low = isbn.lower()
book = None
if volume_id_low == "none" or volume_id_low == "null" or volume_id_low == "":
volume_id = None
if isbn_low == "none" or isbn_low == "null" or isbn_low == "":
isbn = None
# isbnでヒットする本がDBにあったとき
if isbn:
book = Book.query.filter_by(isbn=isbn).first()
# isbnでヒットする本がDBになかったときvolume_idで本を検索
if not book and volume_id:
book = Book.query.filter_by(volume_id=volume_id).first()
# isbnでもvolume_idでも本が見つからなかったときbooksテーブルに保存
if not book:
title = (request.form.get("title") or "").strip()
author = (request.form.get("authors") or "").strip()
page_count = request.form.get("pageCount")
description = request.form.get("description")
cover_image_url = (request.form.get("cover_image_url") or "").strip()
cover_img_url_low = cover_image_url.lower()
if cover_img_url_low == "none" or cover_img_url_low == "null" or cover_img_url_low == "":
cover_image_url = None
source_type = request.form.get("source_type")
new_book = Book(title=title, author=author, isbn=isbn, page_count=page_count, description=description, cover_image_url=cover_image_url, volume_id=volume_id, source_type=source_type)
book = new_book
db.session.add(book)
db.session.commit()
5.AIの活用について
今回のアプリ開発では、ChatGPTを補助的なツールとして活用しました。
ただ、設計や最終的な実装の判断は自分で行うことを意識しました。
5-1 使用した場面
-
実装方針に迷ったとき
機能をどのように実装するか迷ったときに、参考になる記事のソースを出してもらったり、使えるライブラリなどを聞いたりしました。 -
エラーが出たとき
エラーメッセージを読んでも意味が理解できなかったり原因が分からなかったときに、「このエラーが出る原因はどんなことが考えられるか?」問い、自分でエラー原因を究明するようにしました。
5-2 使用の際に意識したこと
そのまま質問を投げると、ChatGPTは回答でコードを返してきます。それを見てしまうと自分で考える力が付かないし勉強にならないと考えました。なのでChatGPTに質問を投げるときは、最初に必ず「コードを出さないで」と入れました。
また、ChatGPTの返す回答に必ず参考のソースも出してもらい確認するようにしました。
今回の開発で学んだこと
アプリ開発は、Pythonスクールで学んだ内容をアウトプットすることを目的として始めましたが、結果的には「動くアプリを作る」だけでなく、「安全に動くアプリを設計する」という、より実務に近い学びを得ることができました。
開発当初は、「ログインできている・データが表示されている」という実装ができたら、これで問題ないと考えていました。
しかし実際アプリを操作すると他人のデータにアクセスできてしまうことがわかり、「認証すれば安全」、「表示できていればOK」ではないということがわかりました。
- DBから情報を取得する時点で、ログイン中のユーザーに紐づくものだけに絞る
- 取得後に、そのデータがログイン中ユーザー本人のものかを確認する
このように実装することで、「ユーザーごとのデータを安全に扱う設計」に対する理解を深めることができました。
今後の展望
今後は、今回作成した読書記録アプリにSNSの機能を取り入れていきたいと考えています。
具体的には、
- 読書記録についての記事を投稿できる機能
- 他のユーザーの投稿を閲覧できる機能
- 気に入った投稿を保存できるお気に入り機能
- いいね機能
などの実装を検討しています。
SNS機能を実装することで、よりプライバシーを考慮した設計を意識したいです。機能も増える分、最初の要件定義やデータベース設計もより詳細かつ具体的に設計しようと思います。
今回の開発で得た学びを活かしながら、より実務に近いアプリケーション設計に挑戦していきます。






