今週で最後の CS50。その集大成として今まで学んだ言語と技術を結集させてウェブアプリの開発をするのが今週の講義内容。前回も JavaScript を使ってある程度の動的なウェブアプリを開発したが、これはクライアントサイドのみ。今回は、データベースを含むサーバーとのやり取りであるバックエンドを含めたウェブアプリの開発を行う。

 

Web programming

前回の講義では、CS50 IDEで http-server と実行すれば、ウェブサーバーが立ち上がった。今回は、これを Flask というライブラリー(または一般的にはフレームワークと呼ばれている)を使って自分で実装してみるところから。

 

Flask(フラスク)

では、Flask とは何か? Flask は Python のライブラリーで、ウェブサーバーを作るためのフレームワークである。フレームワークとは、どのようにファイルを整理し(フォルダの構造など)、どこに何を記述すべきかなどをある程度決めたもののことを言う。

まず、基本的なことは、4つのフォルダ・ファイルを作成することから。

application.py
requirements.txt
static/
templates/
  • application.py は、Python のコードを記述するもの(コントローラー)
  • requirements.txt は、アプリに必要なライブラリーの一覧を保存するもの
  • static/ は、画像ファイルなどの静的データ、CSS、JavaScript などのファイルを保存するディレクトリ
  • templates/ は、HTML を作成するのに必要なファイルを保存する場所

ウェブサーバーのフレームワークは、Flask 以外にも Python 用の Django などがあるが、この講義では動作が軽い Flask を扱う。フレームワークは一般的に MVC と呼ばれる Model(モデル)、View(ビュー)、Controller(コントローラー)という3つの要素から成り立つ。コントローラーが今まで書いてきていたような Python のプログラムのこと。View は、ユーザーインターフェースとなるところ、故に HTML や CSS など。モデルは、アプリのデータがある SQL データベースや CSV ファイルなど。

Flask を使った最も簡単なプログラムは次のようになる。

from flask import Flask

app = Flask(__name__)

@app.route("/")
def index():
    return "hello, world"
  • まず、Flaskflask ライブラリーから読み込む
  • 次に、app 変数にファイル名を Flask 関数の引数として渡す
  • @app.route は、ファイル名のルートに "/" を指示。@ は Python のデコレーターと呼ばれている
  • そして、最後に index() を実行する。これは、デフォルトパス / が呼ばれたときに実行される

サーバーを立ち上げるコマンド flask run を実行すると、hello, world を返す。ただし、これは文字列の hello, world を返しているだけであって、HTML 構文の形では返していない。Chrome を使っている場合は、ディベロッパーツールズより確認可能だ。

では、HTMLを返すようにプログラムを修正してあげるために、render_template ライブラリーを読み込み、それを使って index.html を返す。よって、index.html ファイルに返したい HTML 構文を記述すれば OK。ファイルは、templates ディレクトリに保存することに注意(これは Flask を使う上でのルール)。

from flask import Flask, render_template

app = Flask(__name__)


@app.route("/")
def index():
    return render_template("index.html")

さらに、render_template は複数の引数を取ることができる。

from flask import Flask, render_template, request

app = Flask(__name__)

@app.route("/")
def index():
    return render_template("index.html", name=request.args.get("name", "world"))

変数 name を宣言し、HTML を動的に書き換えることができる。これで、ウェブブラウザ上で URL の最後に /?name=David と入力すれば、結果は、hello, David となる。

<!DOCTYPE html>

<html lang="en">
    <head>
        <title>hello</title>
    </head>
    <body>
        hello, {{ name }}
    </body>
</html>

Google 検索も同じことをしている。例えば、cat の検索結果を表示させたい場合は、/?q=cat をサーバーに渡し、その結果を返している。

 

Forms(フォーム)

次に、ユーザーの名前に対応したページを作るために greet.html を作り、index.html の中にそこに向かうルートと、入力された名前を新ページに渡すプログラムを書く。

<!DOCTYPE html>

<html lang="en">
    <head>
        <title>hello</title>
    </head>
    <body>
        <form action="/greet" method="get">
            <input name="name" type="text">
            <input type="submit">
        </form>
    </body>
</html>
  • /greet ルートを使い、name に名前を代入する
  • applications.py コントローラーでも /greet を追加する必要がある
@app.route("/")
def index():
    return render_template("index.html")


@app.route("/greet")
def greet():
    return render_template("greet.html", name=request.args.get("name", "world"))

 

POST

Form の例では、GET メソッドを使ったことによって、Form に入力したデータが URL に含まれてしまった。これを POST メソッドに変える <form action="/greet" method="post"> 。これに伴い、コントローラーも POST メソッドを取り扱えるように次のように変える必要がある。

@app.route("/greet", methods=["POST"])
def greet():
    return render_template("greet.html", name=request.form.get("name", "world"))

Flask の最も最悪なネーミングの一つで分かりづらい部分が、GET リクエストは request.args 、POST リクエストの場合は request.form と指定する。この変更を加えた後に、再度リンクを辿ると今度は URL に何も表示されていないことが確認できるだろう。

 

Layout(レイアウト)

index.htmlgreet.html では同じ HTML 構文を繰り返さないといけなかったので、Flask テンプレートを使って繰り返している部分を省略することができる(他の Web フレームワークでも可)。そのために、まず基本となるページ(テンプレート) layout.html を作る。

<!DOCTYPE html>

<html lang="en">
    <head>
        <title>hello</title>
    </head>
    <body>
        {% block body %}{% endblock %}
    </body>
</html>

Flask は、Jiinja というテンプレート言語を使用し、その構文である {% %} を用いることで、各ページの共通する部分はプレースホルダーブロックを置き、テンプレートで指定した構文を挿入する。例えば、index.html は次のように書き換えることができる。

{% extends "layout.html" %}

{% block body %}

    <form action="/greet" method="post">
        <input autocomplete="off" autofocus name="name" placeholder="Name" type="text">
        <input type="submit">
    </form>

{% endblock %}

同様に、greet.html は次のようになる。

{% extends "layout.html" %}

{% block body %}

    hello, {{ name }}

{% endblock %}

GET メソッド、POST メソッドを2つ同時に指定することも可能。index() 関数の中で、条件分岐させてあげれば良い。

@app.route("/", methods=["GET", "POST"])
def index():
    if request.method == "GET":
        return render_template("index.html")
    if request.method == "POST":
        return render_template("greet.html", name=request.form.get("name", "world"))

 

ウェブアプリ導入

講師の David が学生の頃に初めて作成した Web アプリケーションを例に講義は進む。

<meta> タグは複数の属性の指定が可能で、viewport メタデータに content 属性を使うことで、スマートフォンなどで開いたときにも見やすいように対応してくれる。

<!DOCTYPE html>

<html lang="en">
    <head>
        <meta name="viewport" content="initial-scale=1, width=device-width">
        <title>froshims</title>
    </head>
    <body>
        {% block body %}{% endblock %}
    </body>
</html>

application.py のプログラムの中で、デフォルトの / ルートに対しては index.html を返すよう指示。

from flask import Flask, render_template, request

app = Flask(__name__)

SPORTS = [
    "Dodgeball",
    "Flag Football",
    "Soccer",
    "Volleyball",
    "Ultimate Frisbee"
]

@app.route("/")
def index():
    return render_template("index.html")

index.html テンプレートを作る。index.html の中身は、生徒の名前と好きなスポーツを選択し、登録してもらうというシンプルなもの。

{% extends "layout.html" %}

{% block body %}
    <h1>Register</h1>

    <form action="/register" method="post">

        <input autocomplete="off" autofocus name="name" placeholder="Name" type="text">
        <select name="sport">
            <option disabled selected value="">Sport</option>
            <option value="Dodgeball">Dodgeball</option>
            <option value="Flag Football">Flag Football</option>
            <option value="Soccer">Soccer</option>
            <option value="Volleyball">Volleyball</option>
            <option value="Ultimate Frisbee">Ultimate Frisbee</option>
        </select>
        <input type="submit" value="Register">

    </form>
{% endblock %}

application.py プログラムの中では、/register ルートに対して POST メソッドでデータの引き渡しをするようにし、ユーザーが何も入力しないで登録しようとした場合にエラーページ(failure.html)をレンダリングする。それ以外の場合は、success.html をレンダリングする。

@app.route("/register", methods=["POST"])
def register():

  if not request.form.get("name") or not request.form.get("sport"):
      return render_template("failure.html")

  return render_template("success.html")

ただし、このプログラムには問題点が存在する。ユーザーが Chrome 等のディベロッパーツールズなどを使えば、スポーツの選択肢を勝手に増やし、その結果を登録できてしまう。サーバーにあるデータを書き換えられないように、HTML にハードコードしていたスポーツの一覧は、代わりに Python のリスト型を宣言し、そこに値をスポーツ一覧を代入する形で対応する。

from flask import Flask, render_template, request

app = Flask(__name__)

SPORTS = [
    "Dodgeball",
    "Flag Football",
    "Soccer",
    "Volleyball",
    "Ultimate Frisbee"
]

@app.route("/")
def index():
    return render_template("index.html", sports=SPORTS)

...

さらには、Flask が非常に便利でかつ秀才と言われる機能がある。それは、次のプログラムのように、Flask のブロックに Python コードを挿入することができる。

...
<select name="sport">
    <option disabled selected value="">Sport</option>
    {% for sport in sports %}
        <option value="{{ sport }}">{{ sport }}</option>
    {% endfor %}
</select>
...

また、Python でも制御する。 not in SPORTS と記述することで、スポーツリストに入っていないスポーツリストが選択された場合は、failure.html をレンダリングする。

...
@app.route("/register", methods=["POST"])
def register():

    if not request.form.get("name") or request.form.get("sport") not in SPORTS:
        return render_template("failure.html")

    return render_template("success.html")

{% extends "layout.html" %}

{% block body %}
    <h1>Register</h1>

    <form action="/register" method="post">

        <input autocomplete="off" autofocus name="name" placeholder="Name" type="text">
        {% for sport in sports %}
            <input name="sport" type="checkbox" value="{{ sport }}"> {{ sport }}
        {% endfor %}
        <input type="submit" value="Register">

    </form>
{% endblock %}

 

データを保存する

生徒が登録ボタンを押した際に、実際にウェブサーバーにデータを登録できるようにプログラムを修正する。そのためには、まず REGISTRANTS = {} を用意する。

from flask import Flask, redirect, render_template, request

app = Flask(__name__)

REGISTRANTS = {}

...

@app.route("/register", methods=["POST"])
def register():

    name = request.form.get("name")
    if not name:
        return render_template("error.html", message="Missing name")

    sport = request.form.get("sport")
    if not sport:
        return render_template("error.html", message="Missing sport")
    if sport not in SPORTS:
        return render_template("error.html", message="Invalid sport")

    REGISTRANTS[name] = sport

    return redirect("/registrants")

登録できない場合に、error.html をレンダリングするだけでなく、意味のあるエラーメッセージを返すために、message 変数を error.html に渡し、表示させる。

{% extends "layout.html" %}

{% block body %}
    {{ message }}
{% endblock %}

一方で、登録が正しくできた場合には、registrants.html をレンダリングする。その際に、REGISTRANTS リストも渡す。

@app.route("/registrants")
def registrants():
    return render_template("registrants.html", registrants=REGISTRANTS)

registrants.html は仮に次のようにする。

{% extends "layout.html" %}

{% block body %}
    <h1>Registrants</h1>
    <table>
        <thead>
            <tr>
                <th>Name</th>
                <th>Sport</th>
            </tr>
        </thead>
        <tbody>
            {% for name in registrants %}
                <tr>
                    <td>{{ name }}</td>
                    <td>{{ registrants[name] }}</td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
{% endblock %}

これで完成のように見えるが、後もう少し。このままではウェブサーバーを再起動させたらこのデータは消えてしまう。よって、データを保存するために、CSV ファイルに書き出したり、データベースに保存したりすることが必要。SQL データベースに保存する場合、まず db = SQL("sqlite:///froshims.db") データベースを作る。

from cs50 import SQL
from flask import Flask, redirect, render_template, request

app = Flask(__name__)

db = SQL("sqlite:///froshims.db")

...

そして、登録ボタンが押される度にグローバル変数に登録するのではなく、代わりに INSERT コマンドを使ってデータベースにデータを保存する。

@app.route("/register", methods=["POST"])
def register():

    name = request.form.get("name")
    if not name:
        return render_template("error.html", message="Missing name")
    sport = request.form.get("sport")
    if not sport:
        return render_template("error.html", message="Missing sport")
    if sport not in SPORTS:
        return render_template("error.html", message="Invalid sport")

    db.execute("INSERT INTO registrants (name, sport) VALUES(?, ?)", name, sport)

    return redirect("/registrants")


@app.route("/registrants")
def registrants():
    registrants = db.execute("SELECT * FROM registrants")
    return render_template("registrants.html", registrants=registrants)

ここで、registrants.html も多少変えてあげる必要がある。registrant in registrantsregistrant.nameregistrant.spots

<tbody>
    {% for registrant in registrants %}
        <tr>
            <td>{{ registrant.name }}</td>
            <td>{{ registrant.sport }}</td>
            <td>
                <form action="/deregister" method="post">
                    <input name="id" type="hidden" value="{{ registrant.id }}">
                    <input type="submit" value="Deregister">
                </form>
            </td>
        </tr>
    {% endfor %}
</tbody>

Flask には、メール flask_mail ライブラリーも用意されている。登録がされた際に、ユーザーにメールを送る際には、次のようにすれば良い。

import os
import re

from flask import Flask, render_template, request
from flask_mail import Mail, Message

app = Flask(__name__)
app.config["MAIL_DEFAULT_SENDER"] = os.getenv("MAIL_DEFAULT_SENDER")
app.config["MAIL_PASSWORD"] = os.getenv("MAIL_PASSWORD")
app.config["MAIL_PORT"] = 587
app.config["MAIL_SERVER"] = "smtp.gmail.com"
app.config["MAIL_USE_TLS"] = True
app.config["MAIL_USERNAME"] = os.getenv("MAIL_USERNAME")
mail = Mail(app)

最後に、register ルートを修正する。

@app.route("/register", methods=["POST"])
def register():

    email = request.form.get("email")
    if not email:
        return render_template("error.html", message="Missing email")
    sport = request.form.get("sport")
    if not sport:
        return render_template("error.html", message="Missing sport")
    if sport not in SPORTS:
        return render_template("error.html", message="Invalid sport")

    message = Message("You are registered!", recipients=[email])
    mail.send(message)

    return render_template("success.html")

メールを送るためには、メールアドレスを知っている必要があるので、名前の登録ではなく、メールアドレスの登録に変更する。

<input autocomplete="off" name="email" placeholder="Email" type="email">

これで、ユーザーが登録したメールアドレスにメールが届くようになる。

 

セッション

セッションは、ウェブサーバーがユーザーの情報を記憶する技術で、ログイン状態を継続したい場合などに使う。ブラウザを閉じて再度開けた際に Gmail にログインしなくても良いように、セッションがあればログインしていた状態をキープしておいてくれる。

HTTP ヘッダーの中にこの情報を入れることが可能で、サーバー側から Set-Cookie を返す。Cookie(クッキー)とは、ウェブサーバーから返される小さいデータの塊のこと(数字の羅列)で、ブラウザがその情報を保存してくれている。そして、Gmail 等のウェブアプリに何度もアクセスする際には、ブラウザがこのユーザーはログインしていたという情報を渡していて、これによってユーザーは何度もログインし直さなくても良い。Facebook や Google などの広告はこれを利用していて、一度訪れたサイトの広告を他のサイトでも表示することができている。

HTTP/1.1 200 OK
Content-Type: text/html
Set-Cookie: session=value
...

では、どう実装するのか?一度ログインしたユーザーをずっとログインしたままにする方法を紹介する。Flask では、flask_session ライブラリーが用意されている。それを使う。

まずは、サイトに訪れたユーザーがログインされていない状態だったら、/login に誘導し、そうでなければ、index.html に誘導する。ここで、ログインされているか状態かどうかは、Flask で用意されているsession ライブラリーを使い、session["name"] を使って確認する。セッションを終了するために、ログアウトする機能を /logout ルートに用意することを忘れずに。

from flask import Flask, redirect, render_template, request, session
from flask_session import Session

app = Flask(__name__)
app.config["SESSION_PERMANENT"] = False
app.config["SESSION_TYPE"] = "filesystem"
Session(app)


@app.route("/")
def index():
    if not session.get("name"):
        return redirect("/login")
    return render_template("index.html")


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        session["name"] = request.form.get("name")
        return redirect("/")
    return render_template("login.html")


@app.route("/logout")
def logout():
    session["name"] = None
    return redirect("/")
  • デフォルト / ルートでは、session に誰の名前も入っていなければ /login へリダイレクトし、それ以外の場合(つまり、誰かの名前が既に入っている)は、index.html テンプレートへリダイレクトする。
  • /login ルートでは、POSTメソッドを使って、sessionname を代入し後、デフォルト / ルートにリダイレクトする。もし、GETメソッドでアクセスされた場合は、login.html を返す。
  • /logout ルートでは, session に代入されている name の値をクリアして、 / にリダイレクトする.
  • 最後に、Flaskのルールの一つである requirements.txt に使用するライブラリーの追加が必要。

 

ログイン画面(login.html)では、名前を入れるシンプルなものを用意(パスワードは後の例で紹介)。

{% extends "layout.html" %}

{% block body %}

    <form action="/login" method="post">
        <input autocomplete="off" autofocus name="name" placeholder="Name" type="text">
        <input type="submit" value="Log In">
    </form>

{% endblock %}

デフォルトページ (index.html) では、session.name が存在するかを確認し、存在する場合はログインをしている画面を、存在しない場合は、ログインを促す画面の表示を動的なプログラムを実装することが可能。

{% extends "layout.html" %}

{% block body %}

    {% if session.name %}
        You are logged in as {{ session.name }}. <a href="/logout">Log out</a>.
    {% else %}
        You are not logged in. <a href="/login">Log in</a>.
    {% endif %}

{% endblock %}

Chrome のディベロッパーツールズでヘッダーファイルを確認すると、Cookie に値を代入されていることが分かる。

Headers tab for a request with Cookie: session=... among others

 

オンラインストア

最後の章では、e-Commerce など、オンラインストアの作り方の紹介。まずは予め用意されたプログラムを見てみる。

application.py の中では、まず初期化、それからデータベースとセッションを使うための設定を行う。続いて、デフォルト / ルートでは、index() で本の一覧を表示するページへレンダリングする。

from cs50 import SQL
from flask import Flask, redirect, render_template, request, session
from flask_session import Session

# Configure app
app = Flask(__name__)

# Connect to database
db = SQL("sqlite:///store.db")

# Configure sessions
app.config["SESSION_PERMANENT"] = False
app.config["SESSION_TYPE"] = "filesystem"
Session(app)


@app.route("/")
def index():
    books = db.execute("SELECT * FROM books")
    return render_template("books.html", books=books)


@app.route("/cart", methods=["GET", "POST"])
def cart():

    # Ensure cart exists
    if "cart" not in session:
        session["cart"] = []

    # POST
    if request.method == "POST":
        id = request.form.get("id")
        if id:
            session["cart"].append(id)
        return redirect("/cart")

    # GET
    books = db.execute("SELECT * FROM books WHERE id IN (?)", session["cart"])
    return render_template("cart.html", books=books)
<!DOCTYPE html>
 
<html>
    <head>
        <meta name="viewport" content="initial-scale=1, width=device-width"/>
        <title>store</title>
    </head>
    <body>
        {% block body %}{% endblock %}
    </body>
</html>


templates/books.html
 では、本の一覧を表示させるだけでなく、そのそれぞれに対して “Add to Cart” (買い物カゴに入れる)ボタンが実装されている。

{% extends "layout.html" %}
 
{% block body %}
 
    <h1>Books</h1>
    {% for book in books %}
        <h2>{{ book.title }}</h2>
        <form action="/cart" method="post">
            <input name="id" type="hidden" value="{{ book.id }}">
            <input type="submit" value="Add to Cart">
        </form>
    {% endfor %}
 
{% endblock %}


その「買い物カゴへ追加」ボタンを押せば、 /cart ルートに引き渡され、POSTリクエストを使って本の id がセッションの変数として入る。一方で、GETメソッドを使った場合は、session に入っている本の一覧(id)が表示される。

{% extends "layout.html" %}
 
{% block body %}
 
    <h1>Cart</h1>
    <ol>
        {% for book in books %}
            <li>{{ book.title }}</li>
        {% endfor %}
    </ol>
 
{% endblock %}

ショッピングカートは、こうして実装できる。

次の例では、好きなテレビ番組のキーワード検索をしたら、データベースに保存されている一覧の中から対応する検索結果を返してくれるというプログラムを見てみる。

まず application.py では、shows.db を開く。デフォルト / ルートでは、検索キーワードを入力できるフォームを用意。そこから更に /search でデータベース上の一致する値を返し、search.html へとレンダリングする。

ここで、更に Google 検索のようにユーザーインターフェースを良くすることを試みる。

from cs50 import SQL
from flask import Flask, render_template, request

app = Flask(__name__)

db = SQL("sqlite:///shows.db")


@app.route("/")
def index():
    return render_template("index.html")


@app.route("/search")
def search():
    shows = db.execute("SELECT * FROM shows WHERE title LIKE ?", "%" + request.args.get("q") + "%")
    return render_template("search.html", shows=shows)

検索キーワードを一文字ずつ入力する都度、検索結果を表示させるようにできる。これには、JavaScript を使えば実装できる。jsonify 関数を使って、JSON フォーマットで値を返す。JSON とは、JavaScript Object Notation の略で、ウェブブラウザとウェブサーバー間でデータをやり取りするためのフォーマットである。

@app.route("/search")
def search():
    shows = db.execute("SELECT * FROM shows WHERE title LIKE ?", "%" + request.args.get("q") + "%")
    return jsonify(shows)
Response for search?q=office with list in JSON

これが JSON フォーマット。このフォーマットを index.html へ渡すことで DOM を書き換えることができる。

<!DOCTYPE html>

<html lang="en">
    <head>
        <meta name="viewport" content="initial-scale=1, width=device-width">
        <title>shows</title>
    </head>
    <body>

        <input autocomplete="off" autofocus placeholder="Query" type="search">

        <ul></ul>

        <script crossorigin="anonymous" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
        <script>

            let input = document.querySelector('input');
            input.addEventListener('keyup', function() {
                $.get('/search?q=' + input.value, function(shows) {
                  let html = '';
                  for (let id in shows)
                  {
                      let title = shows[id].title;
                      html += '<li>' + title + '</li>';
                  }

                  document.querySelector('ul').innerHTML = html;
                });
            });

        </script>

    </body>
</html>

リクエストを簡単に記述するために、JQuery ライブラリーも使用する。input 要素に何か変化がないかをずっと聞いていて、$.get を使ってJQuery ライブラリーを呼び出し、input の値をGET メソッドを使って検索する。結果は shows に返され、<li> で繰り返し表示される。

$.get は、AJAX コールと呼ばれるもので、ページがロードされた後に追加で何かを要求できる機能で、これで一文字ずつ入力した際に違う検索結果を返してくれることが見て取れる(Chrome ディベロッパーツールズ)。

Network tab with requests for search?q=of, search?q=off, etc. and Response with JSON

 

以上、盛りだくさんでした。