Heroku、Flask、SQLAlchemyで掲示板を作る


#はじめに
今回、掲示板の作成について、以下の通り６つに分類にして記述した。
（１）環境構築
（２）csvで掲示板
（３）SQLとSQLAlchemyで掲示板
（４）PostgreSQLとSQLAlchemyで掲示板
（５）SQLAlchemyを使ってデータ操作
（６）Postgresqlを使ってデータ操作


#（１）環境構築
デスクトップにディレクトリtestを作成。
test内に仮想環境を構築して起動。

```
python3 -m venv .
source bin/activate
```

必要なフレームワークとwebサーバーをインストール。

```
pip install flask
pip install gunicorn
```

#（２）csvで掲示板
まず、ローカル環境でcsvを使って掲示板を作る。
##①ディレクトリ構成

```
test
├app.py
├articles.csv
├Procfile
├requirements.txt
└templates
  ├index.html
  ├layout.html
  └index_result.html
```

##②csvデータを用意する
articles.csvを作成し、分かりやすさの観点から、あらかじめ以下のデータを入力しておく。

```
たま,眠いにゃー
しろ,腹減ったにゃー
クロ,なんだか暖かいにゃー
たま,ぽえーぽえーぽえー
ぽんたん,トイレットペーパーがない
なおちん,チーン
```

##③メインとなるapp.pyを作成する

```.py:app.py
#coding: utf-8
from flask import Flask,request,render_template
app = Flask(__name__)

@app.route('/')
def bbs():
    lines = []
    #with openしてcsvファイルを読み込む
    with open('articles.csv',encoding='utf-8') as f:
        lines = f.readlines() #readlinesはリスト形式でcsvの内容を返す
    #index.htmlに返す
    return render_template('index.html',lines=lines)

#postメソッドを受け取る
@app.route('/result',methods=['POST'])
def result():
    #requestでarticleとnameの値を取得する
    article = request.form['article']
    name = request.form['name']
    #csvファイルに上書きモードで書き込む
    with open('articles.csv','a',encoding='utf-8') as f:
        f.write(name + ',' + article + '\n')
    #index_result.htmlに返す
    return render_template('index_result.html',article=article,name=name)


if __name__ == '__main__':
    app.run(debug=False)
```

##④掲示板本体とその他テンプレ

```.html:index.html
{% extends 'layout.html' %}
{% block content %}
    <h1> にゃん子掲示板</h1>
    <form action='/result' method='post'>
        <label for='name'>にゃん子の名前</label>
        <input type='text' name='name'>
        <p></p>
        <label for='article'>投稿</label>
        <input type='text' name='article'>

        <button type='subimit'>書き込む</button>
    </form>

    <p></p>
    <p></p>

    <table border=1>
        <tr><th>にゃん子の名前</th><th>投稿内容</th></tr>
        {% for line in lines: %}
        <!--columnという変数をセット（jinja2の変数セットにはsetが必要）  -->
        <!--splitを利用して,で分類する。splitはリストを返す  -->
            {% set column = line.rstrip().split(',') %}
            <tr><td>{{column[0]}}</td><td>{{column[1]}}</td></tr>
        {% endfor %}
    </table>

{% endblock %}
```


```.html:layout.html
<!DOCTYPE html>
<html lang='ja'>
  <head>
      <meta charset='utf-8'>
      <title>Nyanko BBS</title>
      <style>body{padding:10px;}</style>
  </head>
  <body>
    {% block content %}
    {% endblock %}
  </body>
</html>
```


```html:index_result.html
{% extends 'layout.html' %}
{% block content %}
    <h1>にゃ-んと掲示板に書き込みました</h1>
    <p>{{name}}{{article}}</p>

    <!--formで/に戻る -->
    <form action='/' method='get'>
      <button type='submit'>戻る</button>
    </form>

{% endblock %}
```

##⑤Herokuへデプロイする
ローカル環境でテストした後に、Herokuへデプロイする。
Herokuへのデプロイ詳細は以下の記事に書いた通りなので、エッセンスのみとし、詳細説明を省く。
[Heroku、Flask、Python、Gitでアップロードする方法（その②）](https://qiita.com/nooonchi/items/a7ea7c7a12c56ab8e901)
[Heroku、Flask、Python、Gitでアップロードする方法（その③）](https://qiita.com/nooonchi/items/50d04013bf74ba270960)
Herokuにログインし、Heroku上にアプリを作成

```
heroku login
```


アプリ名はcat-bbsとした。

```
Heroku create cat-bbs
```

ディレクトリappを初期化して、

```
git init
```

Herokuとローカル環境を紐つけて、

```
heroku git:remote -a cat-bbs
```

ディレクトリappにrequirements.txtを作成して、

```
pip freeze > requirements.txt
```

ディレクトリapp内にProcfileを作成し、以下を入力。
この時、gの前はブランク一つ必要、また、:appの前のappは、app.pyのappという意味なので注意が必要（form.pyなら、form:app）

```
web: gunicorn app:app --log-file -
```

全てをaddして、

```
git add .
```

今回は、the-firstという名前でcommitして、

```
git commit -m'the-first'
```

Herokuにpushする。

```
git push heroku master
```

最後に、

```
heroku open
```

heroku openのコマンドを入力すると、ブラウザが立ち上がり以下が表示された。
<img width="389" alt="スクリーンショット 2020-03-10 23.16.27.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29856/a9b2db31-d7ee-073e-b00f-b471012950d0.png">
にゃん子の名前を”いわし”とし、投稿内容を”魚が大好き”と投稿すると、
<img width="404" alt="スクリーンショット 2020-03-10 23.16.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29856/82d2a17f-fcf2-9b1b-165b-7adc4c85aaa0.png">
ちゃんと掲示板に書き込みされた。
herokuでは書き込みされたcsvは一定時間（３０分）経過すると消えてしまうので、データベースの組み込みに取り掛かる。

#（３）SQLとSQLAlchemyで掲示板
##①ディレクトリ構成等

```
test
├app.py
├articles.csv
├Procfile
├requirements.txt
├assets
│ ├init.py   
│ ├database.py
│ └models.py
│
└templates
  ├index.html
  ├layout.html
  └index_result.html
```

SQLAlchemyとは、Pythonの中では最もよく利用されているORMの一つ。 
最初にsqlite3のバージョン確認（Mac）と、sqlalchemyをインストールする。

```
sqlite3 --version
pip install sqlalchemy
```

また、app.pyから、database.pyやmodels.pyをモジュールとして読み込むために必要なファイルとして、 init.pyをassetsフォルダ内に作成する（アンダーバーがつくので注意）

```
touch __init__.py
```

##②SQLAlchemyの初期設定
以下の２つのファイルをassetsフォルダ内に作成する。

database.py・・・sqliteやmysqlなど、どのデータベースを使うのかを定義するファイル
models.py・・・そのデータベースにどのような情報を入れるかを定義するファイル
まず、database.pyは以下の通り。


```.py:database.py
#coding: utf-8

#database.py/sqliteなど、どのデータベースを使うのか初期設定を扱うファイル
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session,sessionmaker
from sqlalchemy.ext.declarative import declarative_base

import datetime
import os

#data_dbという名前で、database.pyのある場所に（os.path.dirname(__file__)）、絶対パスで（os.path.abspath）、data_dbを保存する
database_file = os.path.join(os.path.abspath(os.path.dirname(__file__)),'data.db')

#データベースsqliteを使って（engin)、database_fileに保存されているdata_dbを使う、またechoで実行の際にsqliteを出す（echo=True)
engine = create_engine('sqlite:///' + database_file,convert_unicode=True,echo=True)
db_session = scoped_session(
                sessionmaker(
                    autocommit = False,
                    autoflush = False,
                    bind = engine
                )
            )

#declarative_baseのインスタンス生成する
Base = declarative_base()
Base.query = db_session.query_property()


#データベースの初期化をする関数
def init_db():
    #assetsフォルダのmodelsをインポート
    import assets.models
    Base.metadata.create_all(bind=engine)

```

次に、models.pyは以下の通り。
ここで、投稿日時も掲示板に反映させるようした。

```.py:models.py
#coding: utf-8


from sqlalchemy import Column,Integer,String,Boolean,DateTime,Date,Text
from assets.database import Base
from datetime import datetime as dt

#データベースのテーブル情報
class Data(Base):
    #テーブルnameの設定,dataというnameに設定
    __tablename__ = "data"
    #Column情報を設定、uniqueはFalseとする（同じ値でも認めるという意味）
    #主キーは行を検索する時に必要、通常は設定しておく
    id = Column(Integer,primary_key=True)
    #nameは投稿者
    name = Column(Text,unique=False)
　　 #articleは投稿内容
    article = Column(Text,unique=False)
    #timestampは投稿日時
　　 timestamp = Column(DateTime,unique=False)

    #初期化する
    def __init__(self,name=None,article=None,timestamp=None):
        self.name = name
        self.article = article
        self.timestamp = timestamp
```

##③app.pyを修正する
データベースの作成や削除等には以下２つが必要なためインポートする。
assetsフォルダのdatabaseモジュールから変数de_sessionのインポートと、assetsフォルダのmodelsモジュールから、Dataクラスをインポート。

```
from assets.database import db_session
from assets.models import Data
```

###③−１データベースへの書き込み
index.htmlからarticle、nameの値を取得する処理が必要。また、それぞれの値をで取得時の（書き込み時の）日時をtoday()で取得し、today変数に代入する処理が必要。具体的には以下の通り。

```

article = request.form['article']
name = request.form['name']
today = datetime.datetime.today()
```

上記の内容をrowに格納し、db_sessionとde_commitでデータベースに書き込む処理が必要。具体的には以下の通り。

```
row = Data(name=name,article=article,timestamp=today)
db_session.add(row)
db_session.commit()
```
###③−２データベースからの読み込み
データベースからデータを読み込むには、db_session.query(Data).all()で取得できる。
例えば、データベースの中の値を取り出すために以下のように記述すると、

```
db_session.query(Data.name,Data.article,Data.timestamp).all()
```

以下のようにリスト形式で出力される*（分かりやすさの観点から、掲示板で何件か投稿し、データベースに保存された場合を想定）*

```
('ミケ', '本日は晴れ', datetime.datetime(2020, 3, 13, 0, 7, 4, 828409)),
 ('しろ', '明日は雨だにゃー', datetime.datetime(2020, 3, 13, 0, 7, 4, 828409)),
 ('クロ', 'ぽかぽか', datetime.datetime(2020, 3, 13, 0, 7, 4, 828409)),
 ('ぽんたん', 'にゃーにゃーカラスは紙飛行機', datetime.datetime(2020, 3, 13, 0, 7, 4, 828409)),
 ('しろ', '腰が痛いにゃー', datetime.datetime(2020, 3, 13, 0, 7, 46, 513144)),
 ('ミケ', 'もんだろにゃ？', datetime.datetime(2020, 3, 13, 0, 8, 57, 193710)),
 ('クロ', 'ぽかぽか', datetime.datetime(2020, 3, 13, 0, 9, 42, 45228)),
 ('ミケ', '本日は曇り', datetime.datetime(2020, 3, 13, 0, 17, 13, 709028)),
 ('ブー太郎', '今日は一日雨かにゃー', datetime.datetime(2020, 3, 14, 13, 26, 29, 438012)),
```

index.htmlに読み込んだデータベースの内容を返す処理が必要。具体的には以下の通り。

```
data = db_session.query(Data.name,Data.article,Data.timestamp).all()
return render_template('index.html',data=data)
```

これまでの修正についてまとめると、app.py全体としては以下の通り。

```.py:app.py
#coding: utf-8
from flask import Flask,request,render_template
import datetime

#データベースを使うにあたり追加
from assets.database import db_session
from assets.models import Data

app = Flask(__name__)


@app.route('/')
def bbs():

    #データベースから読み込む
    data = db_session.query(Data.name,Data.article,Data.timestamp).all()

    #index.htmlに返す
    return render_template('index.html',data=data)


#postメソッドを受け取る
@app.route('/result',methods=['POST'])
def result():
    #requestでarticleとnameの値を取得する
    article = request.form['article']
    name = request.form['name']
    #today関数でpostメソッドを受け取った日時を変数に代入
    today = datetime.datetime.today()

    #index_resultからの情報をデータベースに書き込む
    row = Data(name=name,article=article,timestamp=today)
    db_session.add(row)
    db_session.commit()

    #index_result.htmlに返す
    return render_template('index_result.html',article=article,name=name)


if __name__ == '__main__':
    app.run(debug=False)
```

###③−４（参考）データベースからの削除
参考として、読み込んだデータベースからの削除は以下の通り。
db_session.query(Data).allから削除したい項目を指定して（以下のケースは１番目の項目）、de_session.deleteを使う

```
#coding: utf-8

from assets.database import db_session
from assets.models import Data

def csv_sakujo():
    data = db_session.query(Data).all()
    datum = data[0]
    db_session.delete(datum)
    db_session.commit()

csv_sakujo()
```

###③−５（参考）読み込んだデータベースをcsvに書き出し
参考として、読み込んだデータベースをcsvに書き出すファイルは以下の通り。

```.p:to_csv.py
#coding: utf-8

from assets.database import db_session
from assets.models import Data

#データを読み込む
def csv_kakikomi():
    data = db_session.query(Data.name,Data.article,Data.timestamp).all()
    print(data)
    #csvファイルに書き込みモードで書き込む#
    with open('articles2.csv','w',encoding='utf-8') as f:
        for i in data:
            f.write(str(i[0])+',')
            f.write(str(i[1])+',')
            f.write(str(i[2])+',' + '\n')

csv_kakikomi()
```

##④index_html.pyを修正する
app.pyから送られてきたdataの値を表示する。
dataの値のうち、data[2]は現在日時であるが、投稿前はNoneの値があるため、if文でエラーにならないように設定。投稿後はdatatime型をstr型にstrftimeを用いて変換した上で表示。

```.html:index.html
{% extends 'layout.html' %}
{% block content %}
    <h1> にゃん子掲示板</h1>
    <form action='/result' method='post'>
        <label for='name'>にゃん子の名前</label>
        <input type='text' name='name'>
        <p></p>
        <label for='article'>投稿</label>
        <input type='text' name='article'>

        <button type='subimit'>書き込む</button>
    </form>

    <p></p>
    <p></p>

    <table border=1>
        <tr>
          <th>にゃん子の名前</th>
          <th>投稿内容</th>
          <th>投稿日時</th>
        </tr>
        {% for datum in data %}
             <tr>
              <td>{{datum[0]}}</td>
              <td>{{datum[1]}}</td>
              {% if datum[2] == None %}
                  <td>{{datum[2]}}</td>
              {% else %}
                  <td>{{datum[2].strftime('%Y年%m月%d日/%H時%M分%S秒')}}</td>
              {% endif %}
            </tr>
        {% endfor %}
    </table>

{% endblock %}
```

ここまでを、一度ローカル環境で正常に動くかどうかを試す。
<img width="647" alt="スクリーンショット 2020-03-14 22.16.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29856/d19a57e7-235d-46f2-1995-2c79a086189a.png">
問題なく稼働するのを確認したら、次にHeokuへのデプロイと、HerokuのPostgreSQLを使う。
#（４）PostgreSQLとSQLAlchemyで掲示板
Herokuへデプロイし、PostgreSQLを使う。
##①環境準備
postgresqlをbrewを使ってインストールする。

```
brew install postgresql
```

次に postgresqlを使うためのpython用のドライバーとして、 psycopg2-binaryをインストールする。psycopg2をそのままインストールすると、なぜかエラーが出るため、psycopg2-binaryをインストール（原因不明）。

```
pip install  psycopg2-binary
```

次にdatabase.pyを修正するが、environというHeroku上の環境変数を見に行ってDATABASE_URLというデータベースを取得する処理を記述する。environには接続先のURLがセットされる。また、orをつけることで、ローカル環境上はsqliteをデータベースとして参照することとした。herokuに接続されている場合はpostgresqlのurlを参照して、接続されていない場合はsqlを参照に行くという格好。具体的には以下の通り。

```
engine = create_engine(os.environ.get('DATABASE_URL') or 'sqlite:///' + database_file,convert_unicode=True,echo=True)
```

修正後のapp.py全体は以下となる

```.py:database.py
#coding: utf-8

#database.py/sqliteなど、どのデータベースを使うのか初期設定を扱うファイル
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session,sessionmaker
from sqlalchemy.ext.declarative import declarative_base

import datetime
import os

database_file = os.path.join(os.path.abspath(os.path.dirname(__file__)),'data.db')

engine = create_engine(os.environ.get('DATABASE_URL') or 'sqlite:///' + database_file,convert_unicode=True,echo=True)
db_session = scoped_session(
                sessionmaker(
                    autocommit = False,
                    autoflush = False,
                    bind = engine
                )
            )

#declarative_baseのインスタンス生成する
Base = declarative_base()
Base.query = db_session.query_property()


#データベースの初期化をする
def init_db():
    #assetsフォルダのmodelsをインポート
    import assets.models
    Base.metadata.create_all(bind=engine)
```

##②Herokuへデプロイする
Herokuへデプロイする。

```
heroku login
```

Herokuとローカル環境を紐つけて、

```
heroku git:remote -a cat-bbs
```

あらためて、ディレクトリappにrequirements.txtを作成する。
（psycopg2-binaryをインストールしたため、再度の作成が必要。）

```
pip freeze > requirements.txt
```
Prockfileは作成済みのため今回は触らず。


全てをaddして、

```
git add .
```

今回は、the-secondという名前でcommitして、

```
git commit -m'the-second'
```

Herokuにpushする。

```
git push heroku master
```

~~最後にheroku openする~~

herokuにデプロイする前に、データベースの初期化を行う。
heroku上でpythonを起動する（pythonモード）。

```
heroku run python
```

データベースを初期化する。
pythonモードで以下を記述。

```
from assets.database import init_db
init_db()
```

pythonモードを終了し、herokuをrestartして、openする。

```
heroku restart
heroku open
```

ブラウザで以下を確認して成功。
<img width="564" alt="スクリーンショット 2020-03-14 22.58.37.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29856/0a3572c5-ec77-acc3-a522-7a63454329ce.png">
#（５）SQLAlchemyを使ってデータ操作
例として、データベースの１番上の項目を削除してみる（”クロ”）。
Heokuのpythonモード起動。

```
heroku run python
```

pythonモードで以下を記述

```
from assets.database import db_session
from assets.models import Data
data = db_session.query(Data).all()
datum = data[0]
db_session.delete(datum)
db_session.commit()
```

として、heroku openしてブラウザで確認すると、
<img width="563" alt="スクリーンショット 2020-03-14 23.05.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29856/130ab894-7c57-9685-0ce1-1e0ac575ddae.png">
一番上の”クロ”が削除された。
（pythonモードは忘れずに終了させること）

#（６）Postgresqlを使ってデータ操作
PostgreSQL をインストールすると、heroku pg コマンドで Heroku Postgres を操作できるようになる。
例えば以下を入力すると、インストールした Heroku Postgresの状況は以下のように確認できる。

```
heroku pg:info
```

```
=== DATABASE_URL
Plan:                  Hobby-dev
Status:                Available
Connections:           2/20
PG Version:            12.2
Created:               2020-03-14 04:53 UTC
Data Size:             8.2 MB
Tables:                1
Rows:                  3/10000 (In compliance)
Fork/Follow:           Unsupported
Rollback:              Unsupported
Continuous Protection: Off

hobby-dev プラン (無料枠) で、Status は Available (有効) 。
```
以下を入力すると、Heroku Postgresに接続できる。

```
heroku pg:psql
```

接続後はPostgreSQLのコマンドを使用
例えば、１番上の項目を削除してみる（”テスト”、”うんち”）。

```
テーブル一覧の表示のコマンド
\dt;
```

```
テーブル内のデータを一覧するコマンド
select * from data（テーブル名）;
```
以下が出力される。

```

cat-bbs::DATABASE=> select * from data;
 id |    name    |      article       |         timestamp          
----+------------+--------------------+----------------------------
  3 | テスト     | うんち             | 2020-03-14 05:59:38.062361
  4 | プーさん   | なし               | 2020-03-14 15:14:12.453124
  5 | まちゃあき | それがどうした     | 2020-03-14 15:14:12.453124
  6 | どぶろっく | だから             | 2020-03-14 15:14:12.635542
  7 | 変だ       | なし               | 2020-03-14 15:14:12.635542
  8 | おう       | そだね             | 2020-03-14 15:14:12.453124
  9 | ニューあ   | ムーン             | 2020-03-14 15:32:49.082485
 10 | 女子       | 高め               | 2020-03-14 15:59:30.175208
 11 | ほんま     | 相談               | 2020-03-14 15:59:47.029891
 12 | え？       | フォンド           | 2020-03-14 16:15:58.35794
 13 | なおき     | テスト             | 2020-03-14 16:24:47.435301
 14 | ぽち       | ぽちでも猫だにゃん | 2020-03-14 22:52:41.633207
(12 rows)
```
次に、deleteで１番上の項目を削除する（”テスト”、”うんち”）。

```
delete from data（テーブル名） where id=3;
```

とすると、

```
cat-bbs::DATABASE=> select * From data;
 id |    name    |      article       |         timestamp          
----+------------+--------------------+----------------------------
  4 | プーさん   | なし               | 2020-03-14 15:14:12.453124
  5 | まちゃあき | それがどうした     | 2020-03-14 15:14:12.453124
  6 | どぶろっく | だから             | 2020-03-14 15:14:12.635542
  7 | 変だ       | なし               | 2020-03-14 15:14:12.635542
  8 | おう       | そだね             | 2020-03-14 15:14:12.453124
  9 | ニューあ   | ムーン             | 2020-03-14 15:32:49.082485
 10 | 女子       | 高め               | 2020-03-14 15:59:30.175208
 11 | ほんま     | 相談               | 2020-03-14 15:59:47.029891
 12 | え？       | フォンド           | 2020-03-14 16:15:58.35794
 13 | なおき     | テスト             | 2020-03-14 16:24:47.435301
 14 | ぽち       | ぽちでも猫だにゃん | 2020-03-14 22:52:41.633207
(11 rows)
```

削除した。
ブラウザで確認してもちゃんと削除されている。
<img width="546" alt="スクリーンショット 2020-03-14 23.21.23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29856/735c6f55-5f7c-a319-1f1d-013cf9b59334.png">