
【第7回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【メディア管理と非同期処理】
2021.12.20
第7回~モダンなフレームワークの使い方を学びながらブログシステムを構築~

連載「Python Responder + Firestoreでモダンかつサーバーレスなブログシステムを作ってみる」第7回目です。
前回は、記事作成部分を作りました。
メディア管理の整備をしよう
今回はひきつづき、管理者ページの整備をしていきます。
具体的には、「メディアのアップロード部分」を実装しようと思います!
メディアのアップロードページ
まずは、メディアのアップロードページ「/admin/media」を作りましょう。
アップロードに必要な機能
メディアをアップロードするには、以下の機能が必要です。
- ファイルアップロード
- 今あるメディア一覧
- 画像タグ (マークダウン形式) のコピー
画像パスを取得する関数を作る
ビューを作る前に、あらかじめ「画像一覧を取得する関数」を作っておきましょう。
やることは、いたって簡単です。
「/static/images/」のファイルを全て取得し、そのパスをリストで返すだけです。
降順でソートする
ゆくゆくは、日付が新しい順で表示させたいので、降順でソートしておきます。
(ファイル接頭辞に日付を置くためです)
1 2 3 4 5 6 7 8 9 10 11 12 13 | # controllers.py def get_images(image_path='static/images/'): """ Get images existing in the directory, [image_path] """ files = os.listdir(image_path) extensions = ['.png', '.jpg', '.bmp', 'gif'] # 画像拡張子 images = [f'{image_path}{f}' for f in files if os.path.splitext(f)[-1] in extensions # 画像拡張子のみ取得 ] # 降順 = 日付が新しい順 images.sort(reverse=True) return images |
ひとまず、png や jpg などの画像拡張子だけにしました。
ルーティングとビューの作成
ルーティング
ルーティングは、以下の様な感じです。
1 2 3 4 5 6 7 8 9 10 11 12 13 | # controllers.py @api.route('/admin/media') def media(req, resp): if req.cookies.get('session') is None: api.redirect(resp, '/login') # ログイン済み else: # 今ある画像パスを全て取得 imgs = get_images() resp.html = api.template('media.html', name=req.cookies.get('username'), imgs=imgs) |
ビュー
次に、ビューを作ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | <!-- templates/media.html --> {% extends "layout.html" %} {% block content %} <br> <h1>My Blog Name | 管理者ページ</h1> <p>こんにちは,{{ name }} さん</p> <div class="main-container"> <div class="admin-main-menu"> <h2>メディアのアップロード</h2> <form id="fileupload" action="/admin/upload" method="post" enctype="multipart/form-data"> <input name="file" type="file"/> <button type="submit">アップロード</button> </form> <h2>メディア一覧</h2> <div class="media"> {% for img in imgs %} <a onclick="copyToClipboard({{loop.index}})"> <img src="/{{img}}" alt="{{img}}"> </a> <input type="text" id="copy-img-path{{loop.index}}" value="" readonly> {% endfor %} </div> </div> {% include 'admin-side.html'%} </div> <script> function copyToClipboard(num) { let copyTarget = document.getElementById("copy-img-path"+num); copyTarget.select(); document.execCommand("Copy"); alert("以下の画像タグをコピーしました!\n" + "このまま記事にCtrl+Vしてください。\n" + copyTarget.value + "\nもし,代替テキストを変更する場合は [] 内を変更してください!"); } </script> {% endblock %} |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | .media{ width: 100%; background-color: #fff; border: 2px solid #555555; border-radius: 5px; } .media img{ height: 150px; width: 150px; object-fit: contain; border: 1px solid gray; background-color: white; margin: 10px; } .media img:hover{ opacity: 0.7; } .media input{ opacity: 0; position: absolute; } |
Javascript でタグをワンクリックでコピー
ここで、「マークダウン形式のタグを、ワンクリックでクリップボードにコピー」するため、Javascript を使います。
jQuery などは使いません。
記述方法に注意!
その際、 <input type="text" id="copy-img-path{{loop.index}}" value="" readonly> の value をコピー対象にしています。
これを、単純に type="hidden" や CSS で display: none; と記述すると、上手く動作しません。
透明化でコード量を減らす
そのため、少々力技ですが、今回は「透明化」でコード量を少なくします。
1 2 3 4 | .media input{ opacity: 0; position: absolute; } |
上記のように、透明化して、重複を許可しました。
これでもしっかり動作してくれます。
サイドバーにページを追加
そうしたら、このページを、管理者ページのサイドバーに追加しておきましょう。
admin-side.html にまとめてしまい、必要なページにはインクルードする形にしておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 | <!-- admin-side.html --> <div class="admin-side-menu"> <h2>Menu</h2> <ul> <li><a href="/admin/profile">プロフィール確認・編集</a></li> <li><a href="/admin/new">新規追加</a></li> <li><a href="/admin">投稿一覧</a></li> <li><a href="/admin/category">カテゴリ</a></li> <li><a href="/admin/tag">タグ</a></li> <li><a href="/admin/media" target="_blank">メディア</a></li> <li><a href="/logout">ログアウト</a></li> </ul> </div> |
動作確認
それでは、動作するか確認してみましょう。
まずは、「/admin/media」にアクセス。

メディアページ
試しに、画像をクリックしてみます。

クリップボードにタグがコピーされた!
うまく動作しているようですね!
パスに関する注意点
本システムでは、パスの表記に「static/images/***」と「/static/images/***」の2種類を使い分けています。
これは、それぞれ Python 側と HTML / CSS 側で使い分けていますので、ご注意ください!
Python 側で「/static」と表記すると、サーバ側のファイルパスになってしまいます。
アップロード処理の実装
次に、「ファイルアップロード」について実装していきます。
先ほど説明した通り、ビュー側で「/admin/upload」に取得したファイルを投げるのです。
ポイント
押さえるべきポイントは、以下の2点です。
- ファイル名の重複を避けた名前づけ
- ファイルのアップロード機構
日付は接頭辞
1つ目のポイントは、ひとまず「日付(秒まで)を接頭辞」にすれば解決します。
ちなみに、以下のような手段もあるので、完璧なものを作りたい場合は参考にしてください。
- 日付の後ろに、ランダムな文字列を散りばめる
- 投降ユーザ名を入れる
アップロードはバックグラウンド処理
また、せっかくなので、ファイルアップロードはバックグラウンドで処理させましょう。
いざ、Responder で実装
これらを Responder で実装すると、以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | # controllers.py @api.route('/admin/upload') async def upload(req, resp): if req.cookies.get('token') is None: api.redirect(resp, '/login') # ログイン済み else: # アップロードはバックグラウンドで @api.background.task def _upload(data, filename): file = data['file'] f = open('static/images/{}'.format(filename), 'wb') f.write(file['content']) f.close() data = await req.media(format='files') # 画像の名前を被らない名前にする # 今回は日付 + filename # この接頭辞を使って最新の画像は一番上にする now = datetime.today().strftime('%Y%m%d%H%M%S') filename = data['file']['filename'] filename = f'{now}_{filename}' # Upload _upload(data, filename) # media にリダイレクト api.redirect(resp, '/admin/media') |
このように @api.background.task というデコレータをつけるだけで、バックグラウンドで処理させることができます。
これが Responder の良いところですね!
動作確認
それでは、実際に動作を確認してみます!
適当に画像を選択して、アップロードしてみます。

たまたま筆者が持っていたmazda3の画像をあげてみる

しっかりアップロードされた (もともとあった画像には日付がないので順番はバラバラになってしまっていますが無視して下さい)
良い感じですね!
新規記事投稿にサムネイルを追加できるようにする
これで、メディアアップロードが可能になりました。
最後に、記事のサムネイルを追加できるようにしましょう。
サムネイル画像も、同様にマークダウン形式で指定するようにします。
ビューにコード追加
まずは、ビューに以下のコードを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <!-- new.html --> <!-- 省略 --> <div class="main-container"> <div class="admin-main-menu"> <h2>新規投稿</h2> <div class="edit"> <form action="/admin/add" method="post"> <h3>サムネイル</h3> <input type="text" name="thumbnail" value="" placeholder=""> <h3>タイトル</h3> <input type="text" name="title" value=""> <!-- 省略 --> |

サムネイル用 inputタグを追加
コントローラ修正
そして、コントローラを修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | # controllers.py @api.route('/admin/add') class Add: async def on_get(self, req, resp): pass async def on_post(self, req, resp): data = await req.media() title = data.get('title', '') thumbnail = data.get('thumbnail', '') # 変更 description = data.get('description', '') slug = data.get('slug', '').lower().replace(' ', '-') # 全て小文字かつ空白は置換 contents = data.get('contents', '') category = data.get('category', '') tags = data.get_list('tags') # プレビュー の場合 if data.get('preview', None) is not None: # マークダウンからHTMLへ # tableはでデフォルトでは変換してくれないのでここで指定する md = markdown.Markdown(extensions=['tables']) html = md.convert(contents) thumbnail = md.convert(thumbnail) # 追加 md => html whats_new = get_whats_new() categories = get_categories() resp.html = api.template('preview.html', title=title, contents=html, thumbnail=thumbnail, category=category, tags=tags, whats_new=whats_new, categories=categories ) return # ~~ 省略 ~~ # |
プレビュー用のビューを修正
最後に、プレビュー用のビューを修正しましょう。
HTML 形式でサムネイルを得るので、オートエスケープを「無効」にして、展開します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | <!-- preview.html 一部抜粋 --> <div class="article-main"> <!-- メイン --> <div class="title"><h2>{{ title }}</h2></div> <div class="contents"> <div class="info"> YYYY.mm.dd<br> Author: {{ author }} </div> <a href="/category/{{category}}"><span class="category">🏷 {{ category }}</span></a> {% autoescape false%} <div class="thumbnail">{{ thumbnail }}</div> </div> <div class="contents"> {{ contents }} {% endautoescape%} </div> </div> |
これで完了です。
ここまでで、記事の新規追加機能は全て実装できました!
試しに記事を追加してみよう
次に、記事を追加してみましょう。
このとき、いろんなタグを織り交ぜてテスト投稿してみてください。
あとで記事のデザインを調節するのに役立ちます。
サンプル
筆者は、とりあえずこんな感じにしてみました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | ## 新しいブログ「My Blog Name」 この度、ブログを開設しました。 なんと、PythonとFirestoreで動作しています。 ### Responder ResponderはPythonのWebAPIです。 バックエンドで処理され**Webページを動的に、高速に生成**してくれています。 主な特徴は * 非同期処理が簡単に書ける * GraphQLをサポート * 扱いやすい!! インストールは ``` $ pip install responder ``` ### Firestore FirestoreはGoogleが提供するアプリ向けプラットフォーム。 公式によると、 > Google の柔軟でスケーラブルな NoSQL クラウド データベースを使用して、クライアント側開発とサーバー側開発のデータを保存、同期します。 だそう。 ちなみにバージョンは | Tool | Version | |:-----------|:------------| | Responder | 2.0.5 | | Firestore | v1 beta| です。 |

簡単に記事作成ができた
あとで「ブログ一覧取得」や「編集画面実装」にも用いるテスト投稿なので、公開しておきます。
第8回へつづく!
今回は、「メディアアップロードの実装」を行いました。
着々とブログシステムが出来上がってきていますね!
あとは、もう少し管理者ページを充実させていきます。
次回もお楽しみに!
次回の記事はこちら
第1回はこちら
こちらの記事もオススメ!
書いた人はこんな人

- 「好きを仕事にするエンジニア集団」の(株)ライトコードです!
ライトコードは、福岡、東京、大阪の3拠点で事業展開するIT企業です。
現在は、国内を代表する大手IT企業を取引先にもち、ITシステムの受託事業が中心。
いずれも直取引で、月間PV数1億を超えるWebサービスのシステム開発・運営、インフラの構築・運用に携わっています。
システム開発依頼・お見積もり大歓迎!
また、現在「WEBエンジニア」「モバイルエンジニア」「営業」「WEBデザイナー」「WEBディレクター」を積極採用中です!
インターンや新卒採用も行っております。
以下よりご応募をお待ちしております!
https://rightcode.co.jp/recruit
ITエンタメ10月 13, 2023Netflixの成功はレコメンドエンジン?
ライトコードの日常8月 30, 2023退職者の最終出社日に密着してみた!
ITエンタメ8月 3, 2023世界初の量産型ポータブルコンピュータを開発したのに倒産!?アダム・オズボーン
ITエンタメ7月 14, 2023【クリス・ワンストラス】GitHubが出来るまでとソフトウェアの未来