Skip to content
Code Blue プログラミング学習ノート
Python

標準ライブラリでWebアプリとデータベースを連携

はじめに

Pythonの標準ライブラリhttp.serverを使ったWebサーバーの構築からさらにWebアプリケーションとデータベース(SQLite)を連携させる方法を解説します。

Webアプリとデータベースの連携

これまで作成してきたメモ帳アプリケーションは、データをJSONファイルに保存していました。この方法はシンプルで学習には適していますが、実際のWebアプリケーションでは以下のような問題が生じます。

  • 同時アクセス問題 : 複数のユーザーが同時にファイルに書き込もうとすると、データが破損する可能性がある。
  • 検索・抽出の複雑さ : 特定の条件でデータを検索したり、複雑な集計を行うのが難しい。
  • データの整合性 : 関連するデータ間の整合性を保つのが難しい。
  • スケーラビリティ : データ量が増えるとパフォーマンスが著しく低下する。

これらの問題を解決するために、データベース管理システム(DBMS) を使用します。

Pythonには標準でSQLiteがバンドルされており、追加のインストールなしで利用できるため、学習に最適です。

SQLiteの基本

SQLiteはサーバーレスの軽量なリレーショナルデータベースで、以下の特徴があります。

  • 設定が不要で、ファイルベースで動作する
  • Pythonの標準ライブラリに含まれている
  • SQL(構造化クエリ言語)を使用してデータを操作する
  • 小〜中規模のアプリケーションに適している

データベースの接続とテーブル作成

まずはデータベースに接続し、テーブルを作成する基本的なコードを見てみましょう。

import sqlite3

def init_database():
    """データベースを初期化し、テーブルを作成する"""
    # データベースに接続(ファイルが存在しない場合は新規作成)
    conn = sqlite3.connect('memo.db')
    cursor = conn.cursor()

    # メモテーブルを作成
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS memos (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            content TEXT NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    ''')

    # 変更を保存
    conn.commit()
    conn.close()
    print('✅ データベースの初期化が完了しました')

if __name__ == '__main__':
    init_database()

データベース操作用のヘルパー関数

Webアプリケーションからデータベースを操作しやすくするために、ヘルパー関数を作成します。

import sqlite3
from datetime import datetime

class Database:
    """データベース操作用のヘルパークラス"""

    @staticmethod
    def get_connection():
        """データベース接続を取得"""
        return sqlite3.connect('memo.db')

    @staticmethod
    def execute_query(query, params=None):
        """クエリを実行し、結果を返す(SELECT文用)"""
        conn = Database.get_connection()
        cursor = conn.cursor()
        if params:
            cursor.execute(query, params)
        else:
            cursor.execute(query)
        result = cursor.fetchall()
        conn.close()
        return result

    @staticmethod
    def execute_update(query, params=None):
        """更新クエリを実行(INSERT、UPDATE、DELETE文用)"""
        conn = Database.get_connection()
        cursor = conn.cursor()
        if params:
            cursor.execute(query, params)
        else:
            cursor.execute(query)
        last_id = cursor.lastrowid
        conn.commit()
        conn.close()
        return last_id

# データベース操作用の関数
def get_all_memos():
    """すべてのメモを取得(新しい順)"""
    query = '''
        SELECT id, content, created_at, updated_at
        FROM memos
        ORDER BY created_at DESC
    '''
    return Database.execute_query(query)

def add_memo(content):
    """新しいメモを追加"""
    query = 'INSERT INTO memos (content) VALUES (?)'
    return Database.execute_update(query, (content,))

def delete_memo(memo_id):
    """メモを削除"""
    query = 'DELETE FROM memos WHERE id = ?'
    Database.execute_update(query, (memo_id,))

def update_memo(memo_id, content):
    """メモを更新"""
    query = '''
        UPDATE memos 
        SET content = ?, updated_at = CURRENT_TIMESTAMP 
        WHERE id = ?
    '''
    Database.execute_update(query, (content, memo_id))

安全なデータベース操作の重要性

Webアプリケーションでデータベースを操作する際に最も重要なのが、SQLインジェクション対策です。SQLインジェクションとは、悪意のあるユーザーが入力にSQLコードを埋め込み、データベースを不正に操作する攻撃手法です。

危険な例(絶対にやらないでください)

# 危険!ユーザー入力を直接SQLに埋め込む
user_input = request.get('search')
query = f"SELECT * FROM memos WHERE content LIKE '%{user_input}%'"
# もし user_input が "'; DROP TABLE memos; --" だった場合...

安全な方法(パラメータ化クエリ)

# 安全:パラメータ化クエリを使用
user_input = request.get('search')
query = "SELECT * FROM memos WHERE content LIKE ?"
cursor.execute(query, (f'%{user_input}%',))

Webアプリケーションとの統合

それでは、これまでの知識を統合して、データベース連携機能を持ったメモ帳アプリケーションを完成させましょう。

完全なサーバーコード(server.py

from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import sqlite3
import os
import re
from datetime import datetime

# ============ データベース層 ============
class Database:
    @staticmethod
    def get_connection():
        return sqlite3.connect('memo.db')

    @staticmethod
    def execute_query(query, params=None):
        conn = Database.get_connection()
        cursor = conn.cursor()
        if params:
            cursor.execute(query, params)
        else:
            cursor.execute(query)
        result = cursor.fetchall()
        conn.close()
        return result

    @staticmethod
    def execute_update(query, params=None):
        conn = Database.get_connection()
        cursor = conn.cursor()
        if params:
            cursor.execute(query, params)
        else:
            cursor.execute(query)
        last_id = cursor.lastrowid
        conn.commit()
        conn.close()
        return last_id

def init_database():
    """データベースを初期化"""
    conn = sqlite3.connect('memo.db')
    cursor = conn.cursor()

    cursor.execute('''
        CREATE TABLE IF NOT EXISTS memos (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            content TEXT NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    ''')

    conn.commit()
    conn.close()

def get_all_memos():
    query = 'SELECT id, content, created_at FROM memos ORDER BY created_at DESC'
    return Database.execute_query(query)

def add_memo(content):
    query = 'INSERT INTO memos (content) VALUES (?)'
    return Database.execute_update(query, (content,))

def delete_memo(memo_id):
    query = 'DELETE FROM memos WHERE id = ?'
    Database.execute_update(query, (memo_id,))

# ============ テンプレート層 ============
def escape_html(text):
    """XSS対策のためのHTMLエスケープ"""
    replacements = {
        '&': '&',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        ''': '&#39;'
    }
    for char, escape in replacements.items():
        text = text.replace(char, escape)
    return text

def render_template(template_name, context=None):
    """テンプレートをレンダリング"""
    if context is None:
        context = {}

    try:
        with open(f'templates/{template_name}', 'r', encoding='utf-8') as f:
            template = f.read()
    except FileNotFoundError:
        return f'<h1>テンプレートが見つかりません: {template_name}</h1>'

    for key, value in context.items():
        template = template.replace(f'{{{{ {key} }}}}', str(value) if value is not None else '')

    return template

# ============ HTTPハンドラ ============
class MyHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        parsed_url = urlparse(self.path)
        path = parsed_url.path

        if path.startswith('/static/'):
            self.serve_static(path)
            return

        if path == '/':
            self.serve_index()
        elif path.startswith('/delete?id='):
            self.handle_delete(parsed_url)
        else:
            self.send_404()

    def do_POST(self):
        parsed_url = urlparse(self.path)
        path = parsed_url.path

        if path == '/':
            self.handle_add_memo()
        else:
            self.send_404()

    def serve_index(self):
        """メインページを表示"""
        memos = get_all_memos()

        memos_html = ''
        if memos:
            for memo in memos:
                memo_id, content, created_at = memo
                escaped_content = escape_html(content).replace('\n', '<br>')
                memos_html += f'''
                <div class="memo-item">
                    <div class="memo-content">{escaped_content}</div>
                    <div class="memo-meta">
                        <span class="memo-date">{created_at}</span>
                        <a href="/delete?id={memo_id}" class="delete-link" onclick="return confirm('本当に削除しますか?')">削除</a>
                    </div>
                </div>
                '''
        else:
            memos_html = '<p class="no-memos">メモがありません。最初のメモを追加してみましょう!</p>'

        html = render_template('index.html', {
            'memos': memos_html,
            'memo_count': len(memos)
        })

        self.send_response(200)
        self.send_header('Content-type', 'text/html; charset=utf-8')
        self.end_headers()
        self.wfile.write(html.encode('utf-8'))

    def handle_add_memo(self):
        """メモを追加"""
        content_length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(content_length).decode('utf-8')
        params = parse_qs(body)

        content = params.get('content', [''])[0].strip()

        if content:
            add_memo(content)

        self.redirect('/')

    def handle_delete(self, parsed_url):
        """メモを削除"""
        params = parse_qs(parsed_url.query)
        memo_id = params.get('id', [''])[0]

        if memo_id and memo_id.isdigit():
            delete_memo(int(memo_id))

        self.redirect('/')

    def serve_static(self, path):
        """静的ファイルを配信"""
        try:
            filepath = path[1:]
            with open(filepath, 'rb') as f:
                content = f.read()

            if path.endswith('.css'):
                content_type = 'text/css; charset=utf-8'
            elif path.endswith('.js'):
                content_type = 'application/javascript; charset=utf-8'
            else:
                content_type = 'application/octet-stream'

            self.send_response(200)
            self.send_header('Content-type', content_type)
            self.end_headers()
            self.wfile.write(content)

        except FileNotFoundError:
            self.send_404()

    def redirect(self, path):
        """リダイレクト"""
        self.send_response(302)
        self.send_header('Location', path)
        self.end_headers()

    def send_404(self):
        self.send_response(404)
        self.send_header('Content-type', 'text/html; charset=utf-8')
        self.end_headers()
        self.wfile.write('<h1>404 Not Found</h1>'.encode('utf-8'))

# ============ サーバー起動 ============
def run():
    init_database()

    os.makedirs('templates', exist_ok=True)
    os.makedirs('static', exist_ok=True)

    server = HTTPServer(('localhost', 8000), MyHandler)
    print('🚀 データベース連携版メモ帳アプリを起動しました')
    print('📡 アドレス: http://localhost:8000')
    print('💾 データベース: memo.db')
    print('🔄 終了するには Ctrl+C を押してください')

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print('\n🛑 サーバーを停止しました')
        server.server_close()

if __name__ == '__main__':
    run()

テンプレート(templates/index.html

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>メモ帳(データベース版)</title>
<link rel="stylesheet" href="/static/style.css">

<div class="container">
    <header>
        <h1>📝 メモ帳</h1>
        <span class="badge">合計 {{ memo_count }} 件</span>
    </header>

    <section class="add-memo">
        <form method="POST">
            <textarea name="content" placeholder="新しいメモを入力してください..." rows="3"></textarea>
            <button type="submit">メモを追加</button>
        </form>
    </section>

    <section class="memo-list">
        {{ memos }}
    </section>
</div>

CSS(static/style.css

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background: #f0f2f5;
    color: #333;
    line-height: 1.6;
    padding: 20px;
}

.container {
    max-width: 700px;
    margin: 0 auto;
}

header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 30px;
}

h1 {
    font-size: 28px;
    color: #1a1a2e;
}

.badge {
    background: #16213e;
    color: white;
    padding: 4px 12px;
    border-radius: 20px;
    font-size: 14px;
}

.add-memo {
    background: white;
    padding: 20px;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
    margin-bottom: 30px;
}

textarea {
    width: 100%;
    padding: 12px;
    border: 2px solid #e0e0e0;
    border-radius: 8px;
    font-size: 16px;
    font-family: inherit;
    resize: vertical;
    transition: border-color 0.3s;
}

textarea:focus {
    outline: none;
    border-color: #4a90d9;
}

button {
    background: #4a90d9;
    color: white;
    border: none;
    padding: 10px 24px;
    border-radius: 8px;
    font-size: 16px;
    cursor: pointer;
    margin-top: 10px;
    transition: background 0.3s;
}

button:hover {
    background: #357abd;
}

.memo-item {
    background: white;
    padding: 16px 20px;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
    margin-bottom: 12px;
}

.memo-item:last-child {
    margin-bottom: 0;
}

.memo-content {
    font-size: 16px;
    white-space: pre-wrap;
    word-break: break-word;
}

.memo-meta {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 10px;
    padding-top: 10px;
    border-top: 1px solid #f0f0f0;
    font-size: 14px;
    color: #999;
}

.delete-link {
    color: #e74c3c;
    text-decoration: none;
    font-weight: 500;
}

.delete-link:hover {
    text-decoration: underline;
}

.no-memos {
    text-align: center;
    padding: 40px 20px;
    color: #999;
    font-size: 16px;
}

SQLiteの管理コマンド

データベースを直接確認したい場合は、以下のコマンドを使用します。

# SQLiteの対話モードを起動
sqlite3 memo.db

# テーブル一覧を表示
.tables

# テーブルのスキーマを表示
.schema memos

# すべてのデータを表示
SELECT * FROM memos;

# 終了
.quit

データベースを安全かつ効率的に利用するためには、いくつかの重要なポイントがあります。まず、SQLインジェクションを防ぐために、ユーザーが入力した値をSQL文へ直接埋め込むのではなく、必ずパラメータ化クエリを使用することが重要です。また、データベース接続は使用後に必ず conn.close() を実行して適切にクローズし、不要なリソース消費を防ぐ必要があります。

さらに、複数のデータベース操作をまとめて実行する場合は、BEGIN TRANSACTIONCOMMIT を利用してトランザクションを適切に管理することで、データの整合性を保つことができます。データベース操作では例外が発生する可能性があるため、try-except を用いたエラーハンドリングを実装し、異常時にも適切に対応できるようにしておくことも重要です。

パフォーマンスの向上という観点では、頻繁に検索を行うカラムにインデックスを作成することで、検索速度を大幅に改善できます。また、万が一のデータ消失に備えて、データベースファイル(memo.db)を定期的にバックアップする習慣を身につけることも、安全なデータ運用には欠かせません。

まとめ

今回はPythonの標準ライブラリとSQLiteを使用して、Webアプリケーションとデータベースを連携させる方法を学びました。データベースを使用することで、アプリケーションのデータを永続化し、同時アクセスやデータの整合性といった問題に対処できるようになりました。また、パラメータ化クエリを使用したSQLインジェクション対策など、安全なデータベース操作のベストプラクティスも実装しました。これらの知識を基に、より本格的なWebアプリケーション開発に挑戦してみてください。