• トップ
  • ブログ一覧
  • 【第7回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【メディア管理と非同期処理】
  • 【第7回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【メディア管理と非同期処理】

    広告メディア事業部広告メディア事業部
    2020.08.12

    IT技術

    第7回~モダンなフレームワークの使い方を学びながらブログシステムを構築~

    連載「Python Responder + Firestoreでモダンかつサーバーレスなブログシステムを作ってみる」第7回目です。

    前回は、記事作成部分を作りました。

    featureImg2020.08.11【第6回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみる【記事作成画面の実装】第6回~モダンなフレームワークの使い方を学びながらブログシステムを構築~前回は、記事作成部分を作りました。連載「Pyt...

    メディア管理の整備をしよう

    今回はひきつづき、管理者ページの整備をしていきます。

    具体的には、「メディアのアップロード部分」を実装しようと思います!

    メディアのアップロードページ

    まずは、メディアのアップロードページ「/admin/media」を作りましょう。

    アップロードに必要な機能

    メディアをアップロードするには、以下の機能が必要です。

    1. ファイルアップロード
    2. 今あるメディア一覧
    3. 画像タグ (マークダウン形式) のコピー

    画像パスを取得する関数を作る

    ビューを作る前に、あらかじめ「画像一覧を取得する関数」を作っておきましょう。

    やることは、いたって簡単です。

    「/static/images/」のファイルを全て取得し、そのパスをリストで返すだけです。

    降順でソートする

    ゆくゆくは、日付が新しい順で表示させたいので、降順でソートしておきます。

    (ファイル接頭辞に日付を置くためです)

    1# controllers.py
    2def get_images(image_path='static/images/'):
    3    """ Get images existing in the directory, [image_path] """
    4    files = os.listdir(image_path)
    5    extensions = ['.png', '.jpg', '.bmp', 'gif']  # 画像拡張子
    6    images = [f'{image_path}{f}'
    7              for f in files
    8              if os.path.splitext(f)[-1] in extensions  # 画像拡張子のみ取得
    9              ]
    10    # 降順 = 日付が新しい順
    11    images.sort(reverse=True)
    12
    13    return images

    ひとまず、png や jpg などの画像拡張子だけにしました。

    ルーティングとビューの作成

    ルーティング

    ルーティングは、以下の様な感じです。

    1# controllers.py
    2@api.route('/admin/media')
    3def media(req, resp):
    4    if req.cookies.get('session') is None:
    5        api.redirect(resp, '/login')
    6
    7    # ログイン済み
    8    else:
    9        # 今ある画像パスを全て取得
    10        imgs = get_images()
    11        resp.html = api.template('media.html',
    12                                 name=req.cookies.get('username'),
    13                                 imgs=imgs)

    ビュー

    次に、ビューを作ります。

    1<!--
    2templates/media.html
    3-->
    4
    5{% extends "layout.html" %}
    6{% block content %}
    7
    8<br>
    9<h1>My Blog Name | 管理者ページ</h1>
    10<p>こんにちは,{{ name }} さん</p>
    11
    12<div class="main-container">
    13    <div class="admin-main-menu">
    14        <h2>メディアのアップロード</h2>
    15
    16        <form id="fileupload" action="/admin/upload" method="post" enctype="multipart/form-data">
    17            <input name="file" type="file"/>
    18            <button type="submit">アップロード</button>
    19        </form>
    20
    21        <h2>メディア一覧</h2>
    22        <div class="media">
    23            {% for img in imgs %}
    24            <a onclick="copyToClipboard({{loop.index}})">
    25                <img src="/{{img}}" alt="{{img}}">
    26            </a>
    27            <input type="text" id="copy-img-path{{loop.index}}" value="![image_name](/{{img}})" readonly>
    28            {% endfor %}
    29        </div>
    30    </div>
    31
    32        {% include 'admin-side.html'%}
    33
    34</div>
    35<script>
    36    function copyToClipboard(num) {
    37        let copyTarget = document.getElementById("copy-img-path"+num);
    38        copyTarget.select();
    39        document.execCommand("Copy");
    40        alert("以下の画像タグをコピーしました!\n" +
    41            "このまま記事にCtrl+Vしてください。\n" +
    42            copyTarget.value +
    43            "\nもし,代替テキストを変更する場合は [] 内を変更してください!");
    44    }
    45</script>
    46{% endblock %}
    1.media{
    2    width: 100%;
    3    background-color: #fff;
    4    border: 2px solid #555555;
    5    border-radius: 5px;
    6}
    7.media img{
    8    height: 150px;
    9    width: 150px;
    10    object-fit: contain;
    11    border: 1px solid gray;
    12    background-color: white;
    13    margin: 10px;
    14}
    15.media img:hover{
    16    opacity: 0.7;
    17}
    18.media input{
    19    opacity: 0;
    20    position: absolute;
    21}

    Javascript でタグをワンクリックでコピー

    ここで、「マークダウン形式のタグを、ワンクリックでクリップボードにコピー」するため、Javascript を使います。

    jQuery などは使いません。

    記述方法に注意!

    その際、<input type="text" id="copy-img-path{{loop.index}}" value="![image_name](/{{img}})" readonly> の value をコピー対象にしています。

    これを、単純にtype="hidden" や CSS でdisplay: none; と記述すると、上手く動作しません

    透明化でコード量を減らす

    そのため、少々力技ですが、今回は「透明化」でコード量を少なくします。

    1.media input{ 
    2    opacity: 0; 
    3    position: absolute; 
    4}

    上記のように、透明化して、重複を許可しました。

    これでもしっかり動作してくれます。

    サイドバーにページを追加

    そうしたら、このページを、管理者ページのサイドバーに追加しておきましょう。

    admin-side.html にまとめてしまい、必要なページにはインクルードする形にしておきます。

    1<!-- admin-side.html -->
    2<div class="admin-side-menu">
    3    <h2>Menu</h2>
    4    <ul>
    5        <li><a href="/admin/profile">プロフィール確認・編集</a></li>
    6        <li><a href="/admin/new">新規追加</a></li>
    7        <li><a href="/admin">投稿一覧</a></li>
    8        <li><a href="/admin/category">カテゴリ</a></li>
    9        <li><a href="/admin/tag">タグ</a></li>
    10        <li><a href="/admin/media" target="_blank">メディア</a></li>
    11        <li><a href="/logout">ログアウト</a></li>
    12    </ul>
    13</div>

    動作確認

    それでは、動作するか確認してみましょう。

    まずは、「/admin/media」にアクセス。

    メディアページ

    試しに、画像をクリックしてみます。

    クリップボードにタグがコピーされた!

    うまく動作しているようですね!

    パスに関する注意点

    本システムでは、パスの表記に「static/images/***」と「/static/images/***」の2種類を使い分けています。

    これは、それぞれ Python 側と HTML / CSS 側で使い分けていますので、ご注意ください!

    Python 側で「/static」と表記すると、サーバ側のファイルパスになってしまいます。

    アップロード処理の実装

    次に、「ファイルアップロード」について実装していきます。

    先ほど説明した通り、ビュー側で「/admin/upload」に取得したファイルを投げるのです。

    ポイント

    押さえるべきポイントは、以下の2点です。

    1. ファイル名の重複を避けた名前づけ
    2. ファイルのアップロード機構

    日付は接頭辞

    1つ目のポイントは、ひとまず「日付(秒まで)を接頭辞」にすれば解決します。

    ちなみに、以下のような手段もあるので、完璧なものを作りたい場合は参考にしてください。

    1. 日付の後ろに、ランダムな文字列を散りばめる
    2. 投降ユーザ名を入れる

    アップロードはバックグラウンド処理

    また、せっかくなので、ファイルアップロードはバックグラウンドで処理させましょう。

    いざ、Responder で実装

    これらを Responder で実装すると、以下のようになります。

    1# controllers.py
    2@api.route('/admin/upload')
    3async def upload(req, resp):
    4    if req.cookies.get('token') is None:
    5        api.redirect(resp, '/login')
    6
    7    # ログイン済み
    8    else:
    9        # アップロードはバックグラウンドで
    10        @api.background.task
    11        def _upload(data, filename):
    12            file = data['file']
    13            f = open('static/images/{}'.format(filename), 'wb')
    14            f.write(file['content'])
    15            f.close()
    16
    17        data = await req.media(format='files')
    18
    19        # 画像の名前を被らない名前にする
    20        # 今回は日付 + filename
    21        # この接頭辞を使って最新の画像は一番上にする
    22        now = datetime.today().strftime('%Y%m%d%H%M%S')
    23
    24        filename = data['file']['filename']
    25        filename = f'{now}_{filename}'
    26
    27        # Upload
    28        _upload(data, filename)
    29
    30        # media にリダイレクト
    31        api.redirect(resp, '/admin/media')

    このように@api.background.task というデコレータをつけるだけで、バックグラウンドで処理させることができます。

    これが Responder の良いところですね!

    動作確認

    それでは、実際に動作を確認してみます!

    適当に画像を選択して、アップロードしてみます。

    たまたま筆者が持っていたmazda3の画像をあげてみる
    しっかりアップロードされた (もともとあった画像には日付がないので順番はバラバラになってしまっていますが無視して下さい)

    良い感じですね!

    新規記事投稿にサムネイルを追加できるようにする

    これで、メディアアップロードが可能になりました。

    最後に、記事のサムネイルを追加できるようにしましょう。

    サムネイル画像も、同様にマークダウン形式で指定するようにします。

    ビューにコード追加

    まずは、ビューに以下のコードを追加します。

    1<!-- new.html -->
    2
    3<!-- 省略 -->
    4<div class="main-container">
    5    <div class="admin-main-menu">
    6        <h2>新規投稿</h2>
    7        <div class="edit">
    8            <form action="/admin/add" method="post">
    9                <h3>サムネイル</h3>
    10                <input type="text" name="thumbnail" value="" placeholder="![画像の代替テキスト](画像パス)">
    11                <h3>タイトル</h3>
    12                <input type="text" name="title" value="">
    13
    14<!-- 省略 -->
    サムネイル用 inputタグを追加

    コントローラ修正

    そして、コントローラを修正します。

    1# controllers.py
    2@api.route('/admin/add')
    3class Add:
    4    async def on_get(self, req, resp):
    5        pass
    6
    7    async def on_post(self, req, resp):
    8        data = await req.media()
    9        title = data.get('title', '')
    10        thumbnail = data.get('thumbnail', '')  # 変更
    11        description = data.get('description', '')
    12        slug = data.get('slug', '').lower().replace(' ', '-')  # 全て小文字かつ空白は置換
    13        contents = data.get('contents', '')
    14        category = data.get('category', '')
    15        tags = data.get_list('tags')
    16
    17        # プレビュー の場合
    18        if data.get('preview', None) is not None:
    19            # マークダウンからHTML20            # tableはでデフォルトでは変換してくれないのでここで指定する
    21            md = markdown.Markdown(extensions=['tables'])
    22            html = md.convert(contents)
    23
    24            thumbnail = md.convert(thumbnail)  # 追加 md => html
    25
    26            whats_new = get_whats_new()
    27            categories = get_categories()
    28            resp.html = api.template('preview.html',
    29                                     title=title,
    30                                     contents=html,
    31                                     thumbnail=thumbnail,
    32                                     category=category,
    33                                     tags=tags,
    34                                     whats_new=whats_new,
    35                                     categories=categories
    36                                     )
    37            return
    38
    39        # ~~ 省略 ~~ #

    プレビュー用のビューを修正

    最後に、プレビュー用のビューを修正しましょう。

    HTML 形式でサムネイルを得るので、オートエスケープを「無効」にして、展開します。

    1<!-- preview.html 一部抜粋 -->
    2<div class="article-main">
    3
    4        <!--   メイン   -->
    5        <div class="title"><h2>{{ title }}</h2></div>
    6            <div class="contents">
    7                <div class="info">
    8                YYYY.mm.dd<br>
    9                Author: {{ author }}
    10                </div>
    11                <a href="/category/{{category}}"><span class="category">🏷 {{ category }}</span></a>
    12                {% autoescape false%}
    13                <div class="thumbnail">{{ thumbnail }}</div>
    14            </div>
    15
    16        <div class="contents">
    17
    18            {{ contents }}
    19            {% endautoescape%}
    20        </div>
    21    </div>

    これで完了です。

    ここまでで、記事の新規追加機能は全て実装できました!

    試しに記事を追加してみよう

    次に、記事を追加してみましょう。

    このとき、いろんなタグを織り交ぜてテスト投稿してみてください。

    あとで記事のデザインを調節するのに役立ちます。

    サンプル

    筆者は、とりあえずこんな感じにしてみました。

    1##  新しいブログ「My Blog Name」
    2この度、ブログを開設しました。
    3なんと、PythonとFirestoreで動作しています。
    4
    5### Responder
    6ResponderはPythonのWebAPIです。
    7バックエンドで処理され**Webページを動的に、高速に生成**してくれています。
    8主な特徴は
    9
    10* 非同期処理が簡単に書ける
    11* GraphQLをサポート
    12* 扱いやすい!!
    13
    14インストールは
    15
    16```
    17$ pip install responder
    18```
    19
    20### Firestore
    21FirestoreはGoogleが提供するアプリ向けプラットフォーム。
    22公式によると、
    23
    24> Google の柔軟でスケーラブルな NoSQL クラウド データベースを使用して、クライアント側開発とサーバー側開発のデータを保存、同期します。
    25
    26だそう。
    27
    28ちなみにバージョンは
    29
    30| Tool | Version | 
    31|:-----------|:------------|
    32| Responder | 2.0.5 | 
    33| Firestore | v1 beta| 
    34
    35です。
    簡単に記事作成ができた

    あとで「ブログ一覧取得」や「編集画面実装」にも用いるテスト投稿なので、公開しておきます。

    第8回へつづく!

    今回は、「メディアアップロードの実装」を行いました。

    着々とブログシステムが出来上がってきていますね!

    あとは、もう少し管理者ページを充実させていきます。

    次回もお楽しみに!

    次回の記事はこちら

    featureImg2020.08.13【第8回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【管理者ページの仕上げ】第8回~モダンなフレームワークの使い方を学びながらブログシステムを構築~連載「Python Responder + F...

    第1回はこちら

    featureImg2020.07.17【第1回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【初期セットアップ編】モダンなフレームワークの使い方を学びながらブログシステムを構築今回より、また新たに Python WebAPI 関連の...

    こちらの記事もオススメ!

    featureImg2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...

    featureImg2020.07.30Python 特集実装編※最新記事順Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた!P...

    広告メディア事業部

    広告メディア事業部

    おすすめ記事