Django+herokuで本棚アプリ作って公開してみた

Djangoを一通り勉強してWebアプリ(っぽいもの)が作れるようになったので、復習がてら手順を一通りまとめてみました。

作ったもの

本を本棚にコメント付きでしまっておけるアプリです。

本記事では個人情報のない部分を取り出し、開発の一連の流れをまとめました。本検索にはOpenBDを使用して、ISBNコードという本を一意に識別できるコードで検索し、タイトルを自動取得します。

ISBNコードは楽天Booksで検索すると分かります。上部のCREATEから作成フォームに移動し、LISTから今まで作成されたものを見ることができます。

ソースコードは以下を参照してください。

https://github.com/wooolwooolwoool/django_test

注意点としては、Heroku無料版は30分操作がないとサーバーがスリープしてしまいます。そのためフォーム入力に30分以上かけるとエラーになります。20分ごとにWgetするシェルスクリプトを起動しておけばスリープしない、ということもできます。

他に本検索APIとしては他に楽天BooksAPIがあります。それを使用すればISBNコードだけでなく、タイトル、筆者等でも検索することができます。

やったこと

環境構築して、Djangoでアプリを作って、アフィリエイト広告をつけて、Herokuで公開するところまで行いました。Herokuでの操作はGUI上で行いました。(GUI操作を説明しているサイトが少なかったため)アプリの作成から公開まで一連のことをざっとやった感じです。

参考サイト

https://qiita.com/okoppe8/items/54eb105c9c94c0960f14

開発環境

これらは予めインストールしておいてください

環境構築

※この環境構築は自分の環境に合わせて変更してください。Djangoをインストールして、ターミナルが使える状況にあればOKです。

公開してあるGitHubからコードをクローンする。

$ git clone https://github.com/wooolwooolwoool/django_test

DockerfileからDockerイメージを作成する。

$ cd doc/
$ docker build -t book:1.0 .

Dockerコンテナを起動する。このとき$HOME/bookは成果物を保存する場所なので、自由に変更してOK。--nameも変更してOK。

$ docker run --net host -v $HOME/book:/workspace:rw --name book -d book:1.0 bash

プロジェクの作成、準備

VSCodeを起動して、Remote exprolerからさっき作成したDockerコンテナを新しいウィンドウで開きます。 そのウィンドウでターミナルを開き、下記コマンドでプロジェクトを作成します。今回はDjangoチュートリアルに習ってmysiteという名前で作成します。一番後ろのピリオドを忘れるとmanage.pyが作られないので注意です。

$ django-admin startproject mysite .

同じディレクトリにmanage.pyとmysiteができたことを確認します。このmysiteが大元になります。 また今回作成するアプリを追加します。今回はbookという名前です。

$ python manage.py startapp book

同じディレクトリにbookができたことを確認します。

ライブラリインストール

bootstrap4などが必要になるのでインストールします。(Dockerfileを修正したので大丈夫だと思いますが念のため。)

$ pip install dj_database_url django_heroku bootstrap4 requests gunicorn

設定

言語、タイムゾーンを変更します。下記のようにmysite/settings.pyのTIME_ZONEとLANGUAGE_CODEを書き換えてください。

TIME_ZONE = 'Asia/Tokyo'
LANGUAGE_CODE = 'ja'

また、作成したアプリ(book)も追加しておきます。

INSTALLED_APPS = [
    (中略)
    #### 以下を追加
    'book.apps.BookConfig',
    'bootstrap4',
]

後ほど追加するテンプレートのフォルダ('book/templates')も追加しておく。

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'book/templates'),],

アプリの作成

モデルの作成

モデルとはデータベースの形を決めるものです。Bookでは本棚とその本のデータを定義します。 本はISBNコードという本を一意に識別できるコードで検索及び追加を行います。(ただし、別の本棚で同じ本を追加される場合があるのでプライマリキーとはしない。) データは以下のようになります。

  • 本棚 (Bookshelf)
    • タイトル (title 文字列 20文字まで)
    • カテゴリ (cat 整数(選択肢))
  • 本 (Book)
    • タイトル (title 文字列 100文字まで)
    • ISBNコード (isbn 文字列 13文字)
    • コメント (comment 文字列 100文字まで 空欄可)

また本は紐づく本棚も必要なので定義しておきます(target)。 コードは以下の通りです。データの形式にあったフィールドを使用します。 空欄OKの場合は引数にblank=True, null=Trueを渡します。 選択肢で選ばせたい場合には、フィールドをPositiveIntegerFieldとして、Choiceに選択肢を渡します。記載方法はコード参照。プライマリキーは今回指定していないので自動で追加されます。pkで参照可能です。 book/models.pyに下記を記載してください。

from django.db import models

#### Create your models here.

category = (
    (0,"指定なし"),
    (1,"マンガ"),
    (2,"小説"),
    (3,"参考書"),
    (4,"実用書"),
    (5,"教科書"),
    (6,"雑誌"),
)

class Bookshelf(models.Model):
    title = models.CharField(verbose_name="本棚タイトル", 
                             max_length=20)
    cat = models.PositiveIntegerField(
        verbose_name="カテゴリ",
        choices=category, default=0)
    def __str__(self):
        return self.title

class Book(models.Model):
    target = models.ForeignKey(
        Bookshelf, verbose_name='紐づく本棚',
        blank=True, null=True,
        on_delete=models.SET_NULL)
    title = models.CharField(verbose_name="本タイトル", 
                             max_length=100)
    isbn = models.CharField(verbose_name="ISBNコード", 
                             max_length=13)
    comment = models.CharField(verbose_name="コメント", 
                               blank=True, null=True, max_length=100)
    def __str__(self):
        return self.title

htmlでページを作る

これから実際にページやコンテンツを作っていきます。その際実際に動かしながら行うと分かりやすいのでその準備をしていきます。

book/templates/book/というディレクトリを作成します。テンプレートはすべてここに保存していきます。この中にbase.htmlというファイルを作成し、ヘッダやフッタなどを記載しておいてください。base.htmlはほぼすべてのページの枠組みとなるものです。内容については、Gitにあるファイルを参考に作ってください。

URL

アクセス時の動作を決定します。最初に参照されるのは、mysite/urls.pyです。この''が参照されます。今回はbookにアクセスしたいのでurlpatternsでbook.urlsをIncludeします。

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('book.urls')),
]

次にbookでアクセスされたときの動作を決めるために、book/urls.pyのurlpatternsを編集します。例えばlist/にアクセスされた場合はviews.BookList.as_view()が実行されます。このとき返り値はHTTPResponse等である必要があります。

urlpatterns = [
    #### トップページ
    path('', views.top, name='top'), 
    #### 本棚一覧
    path('list/', views.BookList.as_view(), name='book_list'), 
    #### 本棚追加
    path('add/', views.add_post, name='add_post'), 
    #### 本棚詳細
    path('detail/<int:pk>/', views.book_detail, name='book_detail'),
    #### 本の検索(ユーザーからはアクセスされない想定)
    path('serch/<str:key>/', views.book_serch, name='book_serch'),
]

入力フォームの作成

モデルで定義した本棚、本を作成する際の入力フォームを作成します。 新たにbook/forms.pyというファイルを作成し、下記のように記載します。 このとき、詳しくは後述しますがBookのtitleとurlは自動入力とするため、フィールドをreadonlyにしておきます。 本棚と本を関連付けるため、forms.inlineformset_factoryでformsetを作成します。引数は親となるモデル、子となるモデル、子モデルの入力フォームを渡します。また、初期の本の冊数を0、最大冊数を10とするため、extra=0, max_num=10を渡しておきます。

from django import forms
from .models import *

class BookshelfForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super(BookshelfForm, self).__init__(*args, **kwargs)
        for field in self.fields.values():
            field.widget.attrs["class"] = "form-control"

    class Meta:
        model = Bookshelf
        fields = ('title','cat')
        widgets = {
                    'title': forms.TextInput(attrs={'placeholder':'タイトル(50字まで)',}),
                    'category': forms.Select(),
                     }

class BookForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super(Book_dataForm, self).__init__(*args, **kwargs)
        for field in self.fields.values():
            field.widget.attrs["class"] = "form-control"
        self.fields['title'].widget.attrs['readonly'] = 'readonly'
        self.fields['isbn'].widget.attrs['readonly'] = 'readonly'

    class Meta:
        model = Book
        fields = ('title', 'isbn','comment')
        widgets = {
                    'title': forms.TextInput(),
                    'isbn': forms.TextInput(),
                    'comment': forms.Textarea(attrs={'placeholder':'コメント(100字まで)','cols': 1, 'rows': 5}),
                }

BookFormset = forms.inlineformset_factory(
    Bookshelf, Book, BookForm, fields='__all__',
    extra=0, max_num=10,
)

入力フォームの作成(ビュー)

入力フォームを表示させる際に呼ばれるビュー関数を作成します。このときmodelsとformsが必要なのでインポートします。 この関数は最初に訪れるときもSubmitしたときも呼ばれます。その際、request.methodによって処理を分岐します。 各フォームは検証してから保存する必要があるので注意してください。 入力フォーム表示時はpost_form.htmlにformとformsetをレンダリングをして表示、Submit後はデータを保存しbook_listにリダイレクトします。このredirectはURLの他にbook/urls.pyで指定したnameデモ指定することができます。

def add_post(request):
    form = BookshelfForm(request.POST or None)
    context = {'form': form}
    if request.method == 'POST' and form.is_valid():
        post = form.save(commit=False)
        formset = BookFormset(request.POST, files=request.FILES, instance=post)
        if formset.is_valid():
            post.save()
            formset.save()
            return redirect('book_list')
        else:
            context['formset'] = formset
    else:
        context['formset'] = BookFormset()
    return render(request, 'book/post_form.html', context)

入力フォームの作成(HTML)

forms.pyを表示するためのページを作成します。レンダリングするフォームは{{ form.as_p }}のようにするとDjangoがいい感じにしてくれます。本を保存するフォームセットは{{ formset.management_form }}としておきます。本は<tbody class="books">の中にどんどん追加されていきます。

<form>
    <h2>本棚を作る</h2>
    <br>
    {{ form.as_p }}

    <h2>本を追加する</h2>
    {{ formset.management_form }}

    <table class="table table-striped">
        <tbody class="books">
        </tbody>
    </table>

    <input class="form-control" id="search-input-1" placeholder="ISBNコードを入力" type="text" name="search-key-isbn">
    {% csrf_token %}
    <a href="####" id="book-serch" class="btn btn-outline-secondary add-book">本を検索</a><br>
    <p id="serch-warn" class="warn"></p><br><br><br>
    <br><hr>
    <h2 class="caption">本棚を作成する</h2>
    <input class="btn btn-primary" id="create" type="submit" formmethod="post" value="本棚の作成"><br>
    <p id="create-warn" class="warn"></p><br><br><br>     
</form>

検索関数の作成

本はISBNコードから本を検索し、本のフォームを追加しタイトルを自動入力します。 検索にはopenBDを使用します。openBDはISBNコードで本の情報を取得できるサービスです。ただしJavaScriptからアクセスすると拒否される場合がほとんどなので、サーバーからPythonでアクセスする方法を取ります。(PHPやNode.jsでもできるそうですがよくわかりませんでした。誰かいい方法あれば教えてください。。)具体的には/serch/{ISBNコード}/にアクセスすると、ISBNコードでopenBDで本の情報を取得するようviews.pyに関数を作成します。openBDはJSON形式で情報を返却するので、それを解析し情報を自動入力します。今回はタイトルとISBNコードのみを取得します。

処理の流れとしては、「本を検索」ボタンが押されると検索が行われて、本が見つかれば入力欄book-templateをフォーム内table table-stripedに追加して、タイトルとISBNコードを自動入力します。見つからなければそのままスルーです。 入力欄のNameは例えばタイトルであればbook_set-__prefix__-titleの様になります。これはDjango側が決めていることなので、実際に作ってみて、F12で確認してみると早いと思います。

<script type="text/html" id="book-template">
    <tr id="book-__prefix__">
        <td class="align-middle rownum"></td>
        <td>
            {{ formset.empty_form.title }}
            {{ formset.empty_form.isbn }}
            {% bootstrap_field formset.empty_form.comment show_label=True %}
            {% bootstrap_field formset.empty_form.DELETE show_label=False field_class="float-right" %}
        </td>
    </tr>
</script>
<script>
    $(function () {
        $('.add-book').click(function (e) {
            e.preventDefault();
            // 検索ワード取得
            var v0 = document.getElementById('search-input-1').value;
            // ISBNコード欄が空なら検索しない
            if (v0 == ""){                
                return;
            }
            var url = '/serch/' + v0 + "/"; // リクエスト先URL                   
            try{                    
                const request = new XMLHttpRequest();
                request.open('GET', url);
                request.send(null);
            }catch(error){
                document.getElementById("serch-warn").textContent = "見つかりませんでした";
                return;
            }
            request.onreadystatechange = function () {
                if (request.readyState != 4) {
                    // リクエスト中
                } else if (request.status != 200) {
                    document.getElementById("serch-warn").textContent = "見つかりませんでした";
                    return;
                    // 失敗
                } else {
                    // 取得成功
                    var result = request.responseText;                    
                    try{                    
                      // 値が取得できているかの確認
                      var obj = JSON.parse(result);
                      var tmp = obj.summary.title;
                      // 本入力欄の追加
                      var count = parseInt($('####id_book_set-TOTAL_FORMS').attr('value'), 10);
                      var tmplMarkup = $('####book-template').html();
                      var compiledTmpl = tmplMarkup.replace(/__prefix__/g, count);            
                      $('tbody.books').append(compiledTmpl);
                      $('####id_book_set-TOTAL_FORMS').attr('value', count + 1);

                      // 本情報(タイトル、ISBNの入力)
                      var tmp = "book_set-__prefix__-title";
                      var tmp = tmp.replace(/__prefix__/g, count);
                      document.getElementsByName(tmp)[0].value = obj.summary.title;
                      var tmp = "book_set-__prefix__-isbn";
                      var tmp = tmp.replace(/__prefix__/g, count);
                      document.getElementsByName(tmp)[0].value = obj.summary.isbn;

                      // 検索フォームを空に
                      document.getElementById('search-input-1').value = "";
                    }catch(error){
                      // 失敗したらメッセージ表示
                      document.getElementById("serch-warn").textContent = "見つかりませんでした";
                      return;
                    };
                };
            };
        });
    });
</script>

先述の通り、/serch/{ISBNコード}/にアクセスしたとき、ISBNコードでopenBDで本の情報を取得するための関数を作成します。中身の解析はJavascriptの方で行うので、取得したものを返すのみです。

import requests
from django.http import HttpResponse
import json

def book_serch(request, key):
    url = "https://api.openbd.jp/v1/get?isbn=" + key
    html = requests.get(url)
    #### 先頭と最後尾にカッコがついているので取って返却
    return HttpResponse(html.text[1:-1])

本棚一覧の表示

今まで作られた本棚の一覧を表示します。本棚の一覧はListViewを使用するとかなり簡単にかけます。ListViewはmoddelをtemplate_nameにレンダリングしてくれます。また、get_querysetで抽出方法やソートを動的に変更でき、Submitされた値を参照してキーワードで検索したりできます。今回は新たに追加された順に取得します。

class BookList(ListView):
    model = Bookshelf
    template_name = 'book/post_list.html'

    def get_queryset(self):
        return Bookshelf.objects.all().order_by("-pk")

本棚一覧の表示(HTML)

Listviewからはモデルがobject_listにレンダリングされて渡されるため、そこからfor文で取り出します。このとき、選択肢で定義していたデータはそのまま渡すとその番号が表示されてしまうため、get***displayのように記載して渡します。

<h2>本棚一覧</h2><hr>
{% for post in object_list %}
<a href="/detail/{{ post.pk }}/">
  <h3>{{ post.title }}</h3>
  <div>
    カテゴリ:{{ post.get_cat_display }}
  </div>
</a><hr>
{% endfor %}

本棚の詳細

本棚の一覧から本棚を選択すると、本棚の詳細を表示します。本棚の詳細を表示するためには本棚の本を取得します。取得はプライマリキーでフィルタをかければOKです。

def book_detail(request, pk):
    bookshelf = Bookshelf.objects.get(pk=pk)
    books = Book.objects.filter(target__exact=pk).all()

    context = {'bookshelf': bookshelf,
               'books': books}
    return render(request, 'book/post_detail.html', context)

本の詳細(HTML)

本棚の詳細を表示するためのhtmlです。safeを入れておくとcommentにHTMLタグを使用することができ、linebreaksbrを入れておくと、その中に改行が含まれていた場合そのとおりに表示してくれます。

<h1>{{ bookshelf.title }}</h1><hr>
<div>カテゴリ:{{ bookshelf.get_cat_display }}</div><br>

<h2>本一覧</h2><br>
{% for book in books %}
    <h3>{{ book.title }}</h3>
    <div>ISBNコード:{{ book.isbn }}</div><br>
    <div>{{ book.comment | safe | linebreaksbr }}</div><br><hr>
{% endfor %}

トップページの作成

トップページを作成します。この内容は自由なので特に記載しません。

def top(request):
    return render(request, 'book/top.html', {})

動作確認

ここまできたら実際にWebサーバーを起動して動作を確認してみましょう。 下記をmanage.pyと同じディレクトリで実行します。

$ python manage.py makemigrations
$ python manage.py migrate
$ python manage.py runserver

うまくいけば下記のようにサーバーが起動するので、書いてあるURLにアクセスしてみましょう。ただし特別な設定をしていない限り同じPCからしか見ることはできません。

Starting development server at http://127.0.0.1:8080/
Quit the server with CONTROL-C.

追加事項

実行に直接必要ありませんが、やっておくといいことを上げておきます。

アフィリエイト広告を付ける

せっかくなのでアフィリエイト広告をつけておきます。今回は登録すれば簡単に使える楽天アフィリエイトを使います。

https://affiliate.rakuten.co.jp/

上記サイトの メニュー→簡単リンク機能 からバナーが簡単に作れます。好きなところに仕込んでおきます。

CSSでデザインを調整

CSSでデザインを調整します。book/static/css/style.cssというファイルを作成し、ヘッダーで読み込んでいます。気になれば触ってみてください。

その他SEO対策

インデクス登録など、SEO対策をやるといいらしいです。このあたりはよくわかっていません。

https://developers.google.com/search/docs/beginner/seo-starter-guide?hl=ja

ページタイトルを動的に変更

ページタイトル(タブに表示されている文字列)はheadのtitleで定義されていますが、このままではすべて同じタイトルになってしまいます。なのでページごとに記載を変更するようにします。まずはheadのtitleを下記のようにblockで囲います。こうすることで、差し替えられなかったときは「Djangoの試作」、差し替えられたときはその文字列が入ります。

<head>
    (中略)
    <title>{% block title %}Djangoの試作{% endblock %}</title>

差し替えるのは簡単で、book/base.htmlを読み込んだページでblockの中身を書き換えます。これでタブに本棚のタイトルが表示されるようになります。descriptonを操作したい場合にも同様です。

{% block title %}
{{ bookshelf.title }} - Djangoの試作
{% endblock %}

Googleの検索結果に表示されたくないページ

ページによってはGoogleなどの検索エンジンから直接来てほしくないときもあると思います。そんなときはタイトルの時と同様にheadの中に<meta name="robots" content="noindex">を仕込んで置きましょう。

<head>
    (中略)
    {% block noindex %}{% endblock %}
{% block noindex %}
<meta name="robots" content="noindex">
{% endblock %}

Herokuへのデプロイ

アプリを作成したので、これを全世界に公開します。利用するサービスはHerokuを使います。Wepアプリ作成で調べればすぐに出てきます。これは基本利用無料の神のようなサービスです。

準備

デプロイ前に設定を変更しておきます。実は今まではDjangoデバッグモードで動いていました。存在しないURLに行こうとすると長々とエラーメッセージが出ていたと思います。ただこれを利用者に見せるわけにも行かないので、設定を変更します。

https://qiita.com/frosty/items/66f5dff8fc723387108c

このサイトを参考に5. 手順(アップロード編)直前まで行います。やったらアップロードしたGitのURLを覚えておきます。ちなみにPuplicでもPrivateでもOKです。正直いうとこのサイトの最後までやってしまってデプロイするというのもありです。今回はHerokuのGUIから操作していきます。GUIから行う利点としては、初心者でも変なことをしない(できない)点や、デプロイやログがスマホから確認できる点がある。慣れればCLIのほうが早い。

Herokuでの操作

  1. Herokuにログインし、New Appを作成します。
  2. Deploy → App connected to GitHubから作成したリポジトリに接続します。
  3. Manual deploy → Deploy BranchでDeployします。
  4. Deployが成功したことを確認します。
  5. 必要があれば、Automatic Deploysを有効にしておきます。
  6. Settings → Config VarsでSECRET_KEYを設定します。
  7. 右上のMore → Run Consoleからpython manage.py migrateを実行します。
  8. 右上のOpen appからアプリを開きます。

エラーの場合

  • requierment.txtが正しくない(作成したあとにライブラリを追加しているなど)場合にはエラーになります。
  • Procfileを忘れている場合もエラーになります。
  • 詳細はMore → Viwe logsからログを確認できます。