短期集中:PythonでWebゲストブックを作る

Python
この記事は約23分で読めます。

Python勉強企画、6日目もCGIです。1990年代後半の個人ホームページ黎明期に流行していた“ゲストブック”をネタに、ファイルの入出力と日付・時刻情報の扱いを試してみます。

いまはデータベースを利用できるレンタルサーバも多く、ゲストブックのように書き込みが頻繁に発生する場合はデータベースを利用するケースが多いのですが、まだまだファイルアクセスが不要になったわけではありません。

今回使用するファイルアクセスの関数・メソッドは、もちろんCGI以外でも使用できます。

ゲストブックの製作

仕様を考えよう

少し複雑なプログラムになるので、まずは仕様をしっかり決めたいと思います。

ゲストブックとは、名前と1行だけのメッセージを投稿できる、ごく簡易的な掲示版のようなものです。今回は以下のような仕様にしました。

動作

  1. ゲストブックにアクセスすると、投稿フォームと過去の投稿一覧が表示されています。
    投稿フォームは名前と1行コメントだけのごく簡単なものです。

2. 投稿ボタンを押すと、すぐに投稿内容が反映されます。

機能

とりあえず今回はごく基本的な『投稿』『表示』の2つの機能のみを作成します。投稿の修正・削除は実装せず、投稿数や1投稿あたりの文字数の上限も設定しません。つまり投稿されたものはすべて受け入れて、際限なく追加されていきます。

実際にインターネットで公開するにはザルすぎる仕様ですが、あまり複雑にするのもどうかと思うので…。

ファイル構成

今回は実行ファイルの他、データを記録するファイルが必要です。

ファイル名は guestbook.txt とし、実行ファイルと同じディレクトリに保存するとします。

※本当はデータファイルはブラウザからアクセスできない場所に配置した方がいいのですが、今回は説明を簡単にするために同じディレクトリにしています。

guestbook.txt は、初めて投稿があったときに自動的に生成されるものとします。
guestbook.txt が存在しない場合、投稿一覧表示にはファイルが存在しない旨のメッセージを表示します。

データファイルの構造

データファイルに記録する情報は、

  1. 投稿者の名前(文字列、utf-8)
  2. 投稿日時(システムクロックより自動取得、Unix時間)
  3. 投稿されたコメント(文字列、utf-8)

の3つとします。

データファイルの書式は、1回の投稿分(レコードと称します)を1行(改行文字\r\nで区切る)で表現し、上記3つの各情報(フィールドと称します)をタブ文字\tで区切るものとします。

Unix時間とは、1970年1月1日午前0時0分0秒からの経過秒数で現在の日付・時刻を表す形式です。日付・時刻を単一の数値で表すため、日時をこの形式で保存しておき、表示などの時点で任意の書式に変換れば、後で『日時の表示書式を変えたい』となったときに対応しやすいのです。

※日付時刻型のあるデータベースを使用していればこんなことを考える必要はないのですが

なお、古いOSなどUnix時間を32ビット符号付き整数値で表すシステムでは、2038年1月19日に桁あふれをおこして正常に日時を表せなくなる危険があります(いわゆる2038年問題)。

では早速ソースコード

#!C:/Programs/Python/python.exe

import sys
import io
import cgi
import datetime

datafilename = "guestbook.txt"

def displayForm():
    print("Content-Type: text/html; charset=utf-8")
    print()
    print("""<!DOCTYPE html>
<html>
<head>
    <meta charset='utf-8'>
    <title>ゲストブック</title>
</head>
<body>
    <h1>ゲストブック</h1>
    <form action="" method="post">
        お名前 <input type="text" name="name">
        コメント <input type="text" name="comment">
        <button type="submit">投稿</button>
    </form>
    <hr>""")

    try:
        f = open(datafilename, "r", encoding="utf-8")
        lines = f.readlines()
        f.close()        
        lines.reverse()
        print("<table>")
        print("<tr><th>投稿日時</th><th>お名前</th><th>コメント</th></tr>")
        for line in lines:
            [postutime, name, comment] = line.split("\t")
            postdatetime = datetime.datetime.fromtimestamp(int(postutime)).strftime("%Y/%m/%d %H:%M:%S")
            print("<tr><td>%s</td><td>%s</td><td>%s</td></tr>" % (postdatetime, name, comment))
        print("</table>")

    except FileNotFoundError:
        print("ゲストブックのファイルがありません")

    print("""</body>
</html>""")

def sanitize(text):
    chars = "\t\r\n"
    result = text
    for c in chars:
        result = result.replace(c, " ")
    return result

form = cgi.FieldStorage()

if "name" in form and "comment" in form: 
    name    = sanitize(form["name"].value)
    comment = sanitize(form["comment"].value)
    postutime = int(datetime.datetime.now().timestamp())
    line = "%d\t%s\t%s\n" % (postutime, name, comment)
    f = open(datafilename, "a", encoding="utf-8")
    f.write(line)
    f.close()
    print("Location: guestbook.py")
    print()

else:
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
    displayForm()

コード解説

冒頭部分

#!C:/Programs/Python/python.exe

import sys
import io
import cgi
import datetime

datafilename = "guestbook.txt"

例によって1行目はpython.exeのパスを記述しています。

文字コード関連で sysモジュール と ioモジュール、CGIならではの処理用に cgiモジュール、そして今回初めて使うモジュールとして、日付・時刻処理の datetimeモジュールを import しています。

投稿を記録するファイル名は、変数datafilename に代入しておきます。各所に直接ファイル名を記述するのではなくいったん変数に代入しておくと、何らかの理由でファイル名を変更したくなった場合に変更漏れがなくなります。

画面表示(前半)

def displayForm():
    print("Content-Type: text/html; charset=utf-8")
    print()
    print("""<!DOCTYPE html>
<html>
<head>
    <meta charset='utf-8'>
    <title>ゲストブック</title>
</head>
<body>
    <h1>ゲストブック</h1>
    <form action="" method="post">
        お名前 <input type="text" name="name">
        コメント <input type="text" name="comment">
        <button type="submit">投稿</button>
    </form>
    <hr>""")

画面表示は displayForm() 関数で行っています。画面の途中にguestbook.txtから読み出した情報があるので、displayForm() 関数の内容はおおきく3パートに別れています。最初のパートはhttpヘッダの表示とhtmlの前半(静的部分)の表示で、処理は print() 3つだけです。特に説明は要らないでしょう。

ファイルアクセス

    try:
        f = open(datafilename, "r", encoding="utf-8")
        lines = f.readlines()
        f.close()        
        lines.reverse()
        print("<table>")
        print("<tr><th>投稿日時</th><th>お名前</th><th>コメント</th></tr>")
        for line in lines:
            [postutime, name, comment] = line.split("\t")
            postdatetime = datetime.datetime.fromtimestamp(int(postutime)).strftime("%Y/%m/%d %H:%M:%S")
            print("<tr><td>%s</td><td>%s</td><td>%s</td></tr>" % (postdatetime, name, comment))
        print("</table>")

    except FileNotFoundError:
        print("ゲストブックのファイルがありません")

画面表示 displayForm() 関数の2つ目のパートでは、guestbook.txt の読み出しを行っています。

ファイルアクセスの手順

Pythonでは、以下の手順でファイルアクセスを行います。

  1. open()で、アクセスしたいファイルへのファイルオブジェクトを取得する(『ファイルを開く』と表現します)
  2. ファイルを読み書きする
  3. close()でファイルへのアクセスを終了する。書き込みバッファに残っているデータがあったらファイルに書き込む(『ファイルを閉じる』と表現します)
open()

open()はファイルを開くための関数です。書式は、

open(ファイルのパス [、モード [、オプション…] ])

です。

モードは読み取り用・書き込み用などの動作と、扱うデータの種類を表す文字列です。

まず、読み書きの動作を指定する文字列には以下のものがあります。

文字列意味備考
r読み取り専用モードファイルが存在しない場合はFileNotFound例外が発生する
デフォルト動作
w書き込み専用モードファイルが存在しない場合は新規作成する
ファイルが存在する場合は内容を消去する
a追記専用モードファイルが存在しない場合は新規作成する
ファイルが存在する場合は書き込み開始位置を既存の内容の一番最後にする
r+読み書き両用モード

次に、データの種類を指定する文字列には以下のものがあります。

文字列意味備考
tテキストモード読み込み時には改行文字が\nに変換される
書き込み時には\nが各OSにあわせた改行文字に変換される
デフォルト動作
bバイナリモード

テキストデータでの改行文字の扱いがちょっとややこしいです。

※とはいっても、Cのfopen()など、他言語のファイルオープン関数とほぼ同じです。

テキストファイルで使用する改行文字は、

Windows系では \r\n(CR+LF、16進表示で0x0D0A)
Linux系では \n(LF、0x0A)
Mac OSでは \r(CR、0x0D)

と各OSで異なりますが、Pythonの文字列処理では \n が基本です。よって、テキストファイルの読み込み時には、OSによって異なる改行記号をすべて \n に変換して読み込みます。

書き込みの時は逆に、\n を各プラットフォームに応じた形式に変換します。

f = open(datafilename, "r", encoding="utf-8")

guestbook.txt という名前のファイルを、読み込み専用モードでオープンしています。テキストモードなので “rt” としてもよいのですが、”t”は省略可能なので”r”だけにしています。実は”r”も省略可能なのですが、それも省略すると可読性が下がりそうなので…。

オプションの引数、encoding=”utf-8″ によって、ファイル内の文字列を utf-8 と見なして読み込むように指定しています。Python3の標準の文字コードは utf-8 なのですが、encodingを指定しないとファイル入出力に各OSの標準文字コードを使用するようです(Windows系ではShift-JIS)。

※個人的な考えですが、いまから新しくプログラムを作るなら、入出力も utf-8 にしたほうがよいと思います。国際化も楽です。

readlines()

readlines()はファイルオブジェクト(openの返却値)のメソッドで、ファイルの残りの部分をすべて読み込みます。読み込まれたファイルの内容は改行文字によって行ごとに区切られ、各行を要素としたリストとして返されます。

lines = f.readlines()

この例では、readlines()より前にまったく読み込み動作をしていないので、ファイルの先頭から最後までの内容が読み込まれます。このプログラムのデータファイルではレコードを改行で区切っているので、linesには1レコード毎に区切ったリストが代入されます。

ファイルから読み込むメソッドには、他に read() や readline() (最後の複数形のsがない)があります。

read() はファイルの内容の残りすべて(既に読んだ部分の次から最期まで)を読み、一続きの文字列として返します。

readline() は名前から想像できるように、1行(既に読んだ部分の次から改行記号まで)を読み、文字列として返します。

close()

close()はファイルオブジェクトのメソッドで、ファイルを閉じます。引数は不要です。

ファイルアクセスの用事が済んだらすぐに閉じるようにプログラムを記述するのがいいでしょう。

リストを反転する
lines.reverse()

reverse() はリストのメソッドで、要素の順番を反転します。順番を反転したリストを生成して返すのではなく、リストそのものを書き換えます。

このプログラムでは、投稿の表示順を新しい順にするために使用しています。
(書き込みは追記なので、データファイル内では投稿が古い順に保存されています)

レコード数分繰り返して表示
for line in lines:

lines はレコードのリストなので、for で1レコード(1行)ごとに line に代入して繰り返します。

レコードをフィールドに分割
[postutime, name, comment] = line.split("\t")

split()は、文字列を引数で指定した文字で分割し、リストにして返します。このプログラムでは、フィールドをタブ文字\tで区切っているので、split(“\t”) として分割しています。

日付の処理

データファイルにはUnix時刻で記述されているので、”年/月/日 時:分:秒” のフォーマットに変換します。

datetime.datetime.fromtimestamp(int(postutime)).strftime("%Y/%m/%d %H:%M:%S")

ややこしいので順に見ていきます。

1.ファイルから読んだUnix形式の数字文字列を数値化する
int(postutime)

変数postutime はファイルから読み込んだものなので文字列型です。後述のメソッドの引数ではUnix時間は数値型でなければならないので、int()関数で整数値化しておきます。

2.Unix形式の数値をdatetime型のオブジェクトに変換する

日時を任意の形式で文字列化するには、datetime型(日付時刻型)のオブジェクトを使用するのが便利です。Unix形式(数値)からdatetime型オブジェクトに変換するには、datetime.datetime.fromtimestamp()メソッドを使用します。

書式は下記の通りです。引数で指定したUnix時間に対応するdatetime型オブジェクトを生成して返します。

datetime.datetime.fromtimestamp(Unix時間)

3.datetime型のオブジェクトから表示したい書式の文字列を作成する

datetime型のstrftime()メソッドは、datetime型の日時情報から任意の書式の文字列を生成することができます。

書式は以下の通りです。生成した文字列を返します。

datetime型オブジェクト.strftime(書式文字列)

書式文字列は、書式文字(”%〇”) の部分が年・月・日・時・分・秒などの値に置き換えられ、それ以外の文字はそのまま出力されます。

書式文字列はいろいろあるのですが、ここでは以下の6種を使用しています。
PHPのdate()関数など他言語のほぼ同じ機能の関数・メソッドとは書式文字列が微妙に違うので注意して下さい。

書式文字意味
%Y西暦年(4桁、3桁以下の値の場合は上位桁に0を補う)
%m月(1桁の場合は0を補う)
%d日(1桁の場合は0を補う)
%H時(24時間表示、1桁の場合は0を補う)
%M分(1桁の場合は0を補う)
%S秒(1桁の場合は0を補う)

このプログラムのように

.strftime("%Y/%m/%d %H:%M:%S")

とすれば、”2021/08/28 12:34:56″ のような文字列を生成します。

例外処理

Pythonでは、実行時に例外が発生した場合の処理を記述することができます。

書式は、

try:
例外が発生するかもしれない処理
except 処理対象の例外:
例外が発生した婆の処理

です。このプログラムでは、

    try:
        (ファイル読み込み処理)

    except FileNotFoundError:
        print("ゲストブックのファイルがありません")

このプログラムでは、ファイル読み込み処理で FileNotFoudError(読み込もうとしたファイルが存在しない場合)が発生した場合、try内のそれ以降の処理を中断し、『ゲストブックのファイルがありません』と表示します。

画面表示(後半)

    print("""</body>
</html>""")

displayForm()関数の最後は、画面のhtmlの投稿一覧より後の部分を表示しています。print()関数1つだけです。

入力のサニタイズ

def sanitize(text):
    chars = "\t\r\n"
    result = text
    for c in chars:
        result = result.replace(c, " ")
    return result

このプログラムでは、改行文字(\rや\n)をデータファイルのレコードの区切り、タブ文字(\t)をデータファイルのフィールドの区切りとして使用しています。よって、万が一投稿された文字列中に改行文字やタブ文字が含まれていた場合、それらをそのままデータファイルに書き込んでしまうと正常に表示できなくなります。そこで文字列中から \r、\n、\t を半角空白に置換する sanitize() 関数を用意しました。

※インターネットで公開する場合は、クロスサイトスクリプティング対策として、さらに”<“を”&lt;”に置換する、などの処理を追加をするとよいでしょう。

文字列1文字毎にループ

実は文字列もイテラブルデータとして扱われ、

    chars = "\t\r\n"
    for c in chars:

とすると、charsの1文字ずつ(c=”\t”、”\r”、”\n”)で繰り返し処理を行います。

文字の置換

replace() は文字列のメソッドです。書式は

text.replace(chr1, chr2)

です。文字列 text中の文字char1 を文字char2 に置換した文字列を返します。textそのものは変更しませんので、このプログラムでは

        result = result.replace(c, " ")

として変数resultに代入しています。

投稿があった場合の処理

form = cgi.FieldStorage()
if "name" in form and "comment" in form: 
    name = sanitize(form["name"].value)
    comment = sanitize(form["comment"].value)
    postutime = int(datetime.datetime.now().timestamp())
    line = "%d\t%s\t%s\n" % (postutime, name, comment)
    f = open(datafilename, "a", encoding="utf-8")
    f.write(line)
    f.close()

    print("Location: guestbook.py")
    print()
投稿データの取得
form = cgi.FieldStorage()

cgi.FieldStorage() はフォームからの投稿データを取得し、変数formに代入しています。

投稿データがあるかどうかの判定
if "name" in form and "comment" in form: 

フォームから”name”と”comment”が送信されているかどうかを確認しています。

データの取得
name = sanitize(form["name"].value)
comment = sanitize(form["comment"].value)

取得した値は form[キー].value で参照できますが、投稿された値の中に改行やタブ文字が含まれていると正常に動作しなくなるため、上で定義したsanitize()関数を通してから変数に代入しています。

日時の取得とUnix時間への変換
postutime = int(datetime.datetime.now().timestamp())

現在時刻を取得するには、

datetime.datetime.now()

とします。返却値はdatetime型です。

datetime型オブジェクトが表す日時をUnix時間へ変換するには、

datetime型オブジェクト.timestamp()

とします。返却値は浮動小数点数です。datetime型オブジェクト・浮動小数点数で表されるUnix時間のいずれもデータの形式上はマイクロ秒単位の時間を表現できますが、実際にマイクロ秒単位の精度があることは保証されていません。このプログラムでは1秒未満の桁は不要なため、int()関数で整数値化しています。

他にUnix時間を取得する方法として、timeモジュールの time.time() メソッドがあります。

ファイルへの書き込み
    line = "%d\t%s\t%s\n" % (postutime, name, comment)
    f = open(datafilename, "a", encoding="utf-8")
    f.write(line)
    f.close()

まず、ファイルに追記するレコード(行)の文字列を生成します。

    line = "%d\t%s\t%s\n" % (postutime, name, comment)

前回のおみくじにも使用した、%演算子の書式文字列ですが、変数 postutime は整数値のため、書式文字列に%dを使用しています。

最後に改行記号”\n”を明記する必要があることに注意して下さい。

    f = open(datafilename, "a", encoding="utf-8")
    f.write(line)
    f.close()

ファイルへのアクセス手順は読み込みの場合と同じですが、追記モードのため open()関数の第2引数で”a”を指定しています。

write() はファイルオブジェクトのメソッドで、与えられた文字列をファイルに書き込みます。このとき、print() 関数と異なり自動的に改行文字を出力することはありません。よってこのプログラムのようにレコードの末尾に改行記号が必要な場合は、書き込む文字列に明示的に改行記号を付加しておきます。

画面遷移
    print("Location: guestbook.py")
    print()

ファイルへの書き込み後は、Locationヘッダを出力することで自分自身へ画面遷移し、画面表示しています。そんな回りくどいことをせず displayForm()関数を呼び出しても画面は表示されるのですが、その場合はブラウザで画面をリロードすると投稿が二重にファイルに書き込まれてしまいます。プログラム処理中にLocationで遷移することにより『フォームから値を受け取った画面と、表示された画面は別物』という状態にしているわけです。

投稿がなかった場合の処理

else:
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
    displayForm()

フォームからの投稿がなかった場合(ファイル書き込み後にLocationで遷移してきた場合も含む)は、画面を表示します。

    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")

おみくじでも使用した、ブラウザへの出力をutf-8にするための処理です。

表示本体はプログラムの上の方で定義している displayForm()関数を呼ぶだけです。

今回は長かった

プログラムは10分ほどで組んだのに、解説を書くのに何時間もかかってしまいました…。

コメント

タイトルとURLをコピーしました